diff --git a/package-lock.json b/package-lock.json
index 6a4f0abc..ea58ac91 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "imwald",
- "version": "23.7.1",
+ "version": "23.7.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "imwald",
- "version": "23.7.1",
+ "version": "23.7.3",
"license": "MIT",
"dependencies": {
"@asciidoctor/core": "^3.0.4",
@@ -47,7 +47,6 @@
"@radix-ui/react-tabs": "^1.1.2",
"@scure/base": "^2.0.0",
"@tailwindcss/typography": "^0.5.16",
- "@tanstack/react-virtual": "^3.13.24",
"@tiptap/core": "^2.12.0",
"@tiptap/extension-document": "^2.12.0",
"@tiptap/extension-emoji": "^2.26.1",
@@ -1161,9 +1160,9 @@
}
},
"node_modules/@babel/plugin-transform-modules-systemjs": {
- "version": "7.29.0",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz",
- "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==",
+ "version": "7.29.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.4.tgz",
+ "integrity": "sha512-N7QmZ0xRZfjHOfZeQLJjwgX2zS9pdGHSVl/cjSGlo4dXMqvurfxXDMKY4RqEKzPozV78VMcd0lxyG13mlbKc4w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5708,33 +5707,6 @@
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
}
},
- "node_modules/@tanstack/react-virtual": {
- "version": "3.13.24",
- "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz",
- "integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==",
- "license": "MIT",
- "dependencies": {
- "@tanstack/virtual-core": "3.14.0"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/tannerlinsley"
- },
- "peerDependencies": {
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
- "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
- }
- },
- "node_modules/@tanstack/virtual-core": {
- "version": "3.14.0",
- "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz",
- "integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==",
- "license": "MIT",
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/tannerlinsley"
- }
- },
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
diff --git a/package.json b/package.json
index a683d2de..6bd51333 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "imwald",
- "version": "23.7.1",
+ "version": "23.7.3",
"description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true,
"type": "module",
@@ -77,7 +77,6 @@
"@radix-ui/react-tabs": "^1.1.2",
"@scure/base": "^2.0.0",
"@tailwindcss/typography": "^0.5.16",
- "@tanstack/react-virtual": "^3.13.24",
"@tiptap/core": "^2.12.0",
"@tiptap/extension-document": "^2.12.0",
"@tiptap/extension-emoji": "^2.26.1",
diff --git a/src/components/NewNotesButton/index.tsx b/src/components/NewNotesButton/index.tsx
index ea5cd84b..39bcab43 100644
--- a/src/components/NewNotesButton/index.tsx
+++ b/src/components/NewNotesButton/index.tsx
@@ -32,7 +32,7 @@ export default function NewNotesButton({
{newEvents.length > 0 && (
boolean
/** Override default cap for merged one-shot batches (wide d-tag / search merges). */
oneShotMergedCap?: number
+ /** When every relay in the subscribe wave fails before EOSE, merge a one-shot fetch from default read relays (home multi-relay feeds). */
+ timelinePublicReadFallback?: boolean
}>(function NormalFeed(
{
subRequests,
@@ -106,7 +108,8 @@ const NormalFeed = forwardRef
>
diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx
index 9e22a60e..86ee2838 100644
--- a/src/components/Note/index.tsx
+++ b/src/components/Note/index.tsx
@@ -478,7 +478,7 @@ export default function Note({
userId={event.pubkey}
size={size === 'small' ? 'medium' : 'normal'}
maxFileSizeKb={showFull ? 2048 : 500}
- deferRemoteAvatar={!showFull}
+ deferRemoteAvatar={false}
/>
diff --git a/src/components/NoteList/VirtualizedFeedRows.tsx b/src/components/NoteList/VirtualizedFeedRows.tsx
deleted file mode 100644
index fb7715ea..00000000
--- a/src/components/NoteList/VirtualizedFeedRows.tsx
+++ /dev/null
@@ -1,167 +0,0 @@
-import NoteCard from '@/components/NoteCard'
-import MediaGridItem from '@/components/MediaGridItem'
-import { useVirtualizer, useWindowVirtualizer } from '@tanstack/react-virtual'
-import type { Event } from 'nostr-tools'
-import { memo } from 'react'
-
-const ESTIMATE_NOTE_ROW_PX = 280
-const ESTIMATE_GRID_ROW_PX = 120
-/** Smaller overscan reduces stacked off-screen rows when scroll sync is briefly wrong (Firefox paint glitches). */
-const VIRTUAL_OVERSCAN = 4
-
-export type VirtualizedFeedRowsProps = {
- events: Event[]
- gridLayout: boolean
- filterMutedNotes: boolean
- eventReasonLabelMap: Map
- /** When true, list scrolls with `window`; otherwise `scrollElement` must be set. */
- useWindowScroll: boolean
- scrollElement: HTMLElement | null
- /** Document offset of the list root (window virtualizer scroll margin). */
- scrollMarginTop: number
-}
-
-const WindowRows = memo(function WindowRows({
- events,
- gridLayout,
- filterMutedNotes,
- eventReasonLabelMap,
- scrollMarginTop
-}: Omit) {
- const rowCount = gridLayout ? Math.ceil(events.length / 3) : events.length
- const virtualizer = useWindowVirtualizer({
- count: rowCount,
- estimateSize: () => (gridLayout ? ESTIMATE_GRID_ROW_PX : ESTIMATE_NOTE_ROW_PX),
- overscan: VIRTUAL_OVERSCAN,
- scrollMargin: scrollMarginTop,
- // Stable keys by event id so prepending new feed rows does not remount existing rows (e.g. reply editor state).
- getItemKey: (index) =>
- gridLayout ? `grid-${index}` : (events[index]?.id ?? `row-${index}`)
- })
-
- return (
-
- {virtualizer.getVirtualItems().map((vi) => (
-
- {gridLayout ? (
-
- {events.slice(vi.index * 3, vi.index * 3 + 3).map((event) => (
-
- ))}
-
- ) : (
-
- )}
-
- ))}
-
- )
-})
-
-const ElementRows = memo(function ElementRows({
- events,
- gridLayout,
- filterMutedNotes,
- eventReasonLabelMap,
- scrollElement
-}: Omit & {
- scrollElement: HTMLElement
-}) {
- const rowCount = gridLayout ? Math.ceil(events.length / 3) : events.length
- const virtualizer = useVirtualizer({
- count: rowCount,
- getScrollElement: () => scrollElement,
- estimateSize: () => (gridLayout ? ESTIMATE_GRID_ROW_PX : ESTIMATE_NOTE_ROW_PX),
- overscan: VIRTUAL_OVERSCAN,
- // Stable keys by event id so prepending new feed rows does not remount existing rows (e.g. reply editor state).
- getItemKey: (index) =>
- gridLayout ? `grid-${index}` : (events[index]?.id ?? `row-${index}`)
- })
-
- return (
-
- {virtualizer.getVirtualItems().map((vi) => (
-
- {gridLayout ? (
-
- {events.slice(vi.index * 3, vi.index * 3 + 3).map((event) => (
-
- ))}
-
- ) : (
-
- )}
-
- ))}
-
- )
-})
-
-/** Window- or element-scrolling virtual list for feed rows (and 3-column media grid by row). */
-export default memo(function VirtualizedFeedRows({
- events,
- gridLayout,
- filterMutedNotes,
- eventReasonLabelMap,
- useWindowScroll,
- scrollElement,
- scrollMarginTop
-}: VirtualizedFeedRowsProps) {
- if (events.length === 0) {
- return null
- }
-
- if (useWindowScroll) {
- return (
-
- )
- }
-
- if (!scrollElement) {
- return null
- }
-
- return (
-
- )
-})
diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx
index acbf32dd..f9b3b640 100644
--- a/src/components/NoteList/index.tsx
+++ b/src/components/NoteList/index.tsx
@@ -1,5 +1,11 @@
import NewNotesButton from '@/components/NewNotesButton'
-import { ExtendedKind, FIRST_RELAY_RESULT_GRACE_MS, SINGLE_RELAY_KINDLESS_EOSE_TIMEOUT_MS, SINGLE_RELAY_KINDLESS_REQ_LIMIT } from '@/constants'
+import {
+ ExtendedKind,
+ FAST_READ_RELAY_URLS,
+ FIRST_RELAY_RESULT_GRACE_MS,
+ SINGLE_RELAY_KINDLESS_EOSE_TIMEOUT_MS,
+ SINGLE_RELAY_KINDLESS_REQ_LIMIT
+} from '@/constants'
import {
collectEmbeddedEventPrefetchTargets,
getNip18RepostTargetId,
@@ -66,7 +72,6 @@ import { createPortal } from 'react-dom'
import { toast } from 'sonner'
import { formatPubkey, inviteInputToHexPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { usePrimaryPageOptional } from '@/contexts/primary-page-context'
-import { usePrimaryPageScrollAreaRefOptional } from '@/contexts/primary-page-scroll-area-context'
import type { TPrimaryPageName } from '@/PageManager'
import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/providers/NoteFeedProfileContext'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
@@ -83,8 +88,8 @@ import {
SelectTrigger,
SelectValue
} from '@/components/ui/select'
-import { NoteCardLoadingSkeleton } from '../NoteCard'
-import VirtualizedFeedRows from './VirtualizedFeedRows'
+import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
+import MediaGridItem from '../MediaGridItem'
const LIMIT = 150 // Per-shard REQ limit for timeline + loadMore (larger batches = fewer round-trips)
const ALGO_LIMIT = 200 // Increased from 500 for algorithm feeds
@@ -128,6 +133,8 @@ const LOAD_MORE_SCROLL_PREFETCH_VIEWPORT_MULT = 2.35
const LOAD_MORE_SCROLL_PREFETCH_MIN_PX = 960
/** Min ms between scroll-driven load-more attempts (loadMore also throttles internally). */
const LOAD_MORE_SCROLL_PREFETCH_COOLDOWN_MS = 180
+/** When the scroll container is within this many px of the top, auto-merge pending live notes (see {@link NewNotesButton}). */
+const AUTO_MERGE_NEW_EVENTS_TOP_PX = 120
function getNearestScrollableAncestor(node: HTMLElement | null): HTMLElement | null {
if (!node) return null
@@ -140,23 +147,6 @@ function getNearestScrollableAncestor(node: HTMLElement | null): HTMLElement | n
return null
}
-/** Scrollport used by {@link VirtualizedFeedRows} — must sit on the same DOM chain as the list rows. */
-function resolveFeedVirtualScrollAnchor(root: HTMLElement | null, listAnchor: HTMLElement | null): HTMLElement | null {
- return listAnchor ?? root
-}
-
-/** Prefer the layout’s primary scroll div when the feed is inside it; otherwise walk ancestors. */
-function resolvePrimaryFeedScrollPort(
- layoutScrollEl: HTMLElement | null,
- anchor: HTMLElement | null
-): HTMLElement | null {
- if (!anchor) return null
- if (layoutScrollEl && layoutScrollEl.contains(anchor)) {
- return layoutScrollEl
- }
- return getNearestScrollableAncestor(anchor)
-}
-
function distanceFromScrollBottom(scrollRoot: HTMLElement | Window): number {
if (scrollRoot === window) {
const doc = document.documentElement
@@ -705,7 +695,12 @@ const NoteList = forwardRef(
feedClientFilterTabRowHost,
onSingleRelayKindlessEmpty,
feedTopNotice,
- gridLayout = false
+ gridLayout = false,
+ /**
+ * When true (multi-relay home feeds): if every relay in the subscribe wave fails before EOSE, run one
+ * {@link client.fetchEvents} against {@link FAST_READ_RELAY_URLS} so the feed is not stuck on stale cache only.
+ */
+ timelinePublicReadFallback = false
}: {
subRequests: TFeedSubRequest[]
showKinds: number[]
@@ -760,6 +755,7 @@ const NoteList = forwardRef(
feedTopNotice?: ReactNode
/** When true, render events as an Instagram-style 3-column square media grid. */
gridLayout?: boolean
+ timelinePublicReadFallback?: boolean
},
ref
) => {
@@ -778,6 +774,7 @@ const NoteList = forwardRef(
const feedFullSearchEventsRef = useRef(null)
const displayTimelineSourceRef = useRef([])
const [newEvents, setNewEvents] = useState([])
+ const newEventsRef = useRef([])
const [hasMore, setHasMore] = useState(true)
const [loading, setLoading] = useState(true)
/** Session/IDB/relay layers still running for {@link progressiveWarmupQuery} feeds (drives “Looking for more…”). */
@@ -793,7 +790,6 @@ const NoteList = forwardRef(
const [feedClientTimeAmount, setFeedClientTimeAmount] = useState('')
const [feedClientTimeUnit, setFeedClientTimeUnit] = useState('day')
const supportTouch = useMemo(() => isTouchDevice(), [])
- const primaryScrollAreaRef = usePrimaryPageScrollAreaRefOptional()
const timelineEventsForFilter = feedFullSearchEvents ?? events
@@ -807,12 +803,6 @@ const NoteList = forwardRef(
const bottomRef = useRef(null)
/** List root for intersection / load-more wiring (outer NoteList shell). */
const feedRootRef = useRef(null)
- /**
- * Wrapper around the virtualized list block — closer to rows than {@link feedRootRef}, so
- * {@link getNearestScrollableAncestor} picks the same scrollport the user actually scrolls (e.g.
- * `react-simple-pull-to-refresh`’s inner panel on touch, or the primary page div on desktop).
- */
- const feedListScrollAnchorRef = useRef(null)
const topRef = useRef(null)
const spellFeedFirstPaintLoggedKeyRef = useRef('')
const consecutiveEmptyRef = useRef(0) // Track consecutive empty results to prevent infinite retries
@@ -853,6 +843,8 @@ const NoteList = forwardRef(
const emptyRelayNoHitsToastKeyRef = useRef('')
/** Per-relay outcomes for the current subscribe wave (merged shards); drives empty-feed toast detail. */
const [feedSubscribeRelayOutcomes, setFeedSubscribeRelayOutcomes] = useState([])
+ /** One-shot per timeline init: after an all-failed relay wave, try {@link FAST_READ_RELAY_URLS}. */
+ const publicReadFallbackAttemptedRef = useRef(false)
/**
* Bumped when {@link feedPaintLiveRelayDoneRef} becomes true so the empty-feed toast effect re-runs.
* (Loading clears when subscribe wires; merged EOSE arrives later.)
@@ -1029,6 +1021,7 @@ const NoteList = forwardRef(
const followingFeedDeltaCloserRef = useRef<(() => void) | null>(null)
useLayoutEffect(() => {
+ publicReadFallbackAttemptedRef.current = false
setFeedTimelineEmptyUiReady(false)
setFeedSubscribeRelayOutcomes([])
}, [timelineSubscriptionKey, refreshCount])
@@ -1140,6 +1133,24 @@ const NoteList = forwardRef(
const withKindFilterRef = useRef(withKindFilter)
withKindFilterRef.current = withKindFilter
+ const narrowLiveBatchUsingRefs = (evs: Event[]): Event[] => {
+ if (allowKindlessRelayExploreRef.current && showAllKindsRef.current) return evs
+ if (withKindFilterRef.current && !showAllKindsRef.current) {
+ return evs.filter((e) =>
+ eventPassesNoteListKindPicker(
+ e,
+ effectiveShowKindsRef.current,
+ showKind1OPsRef.current,
+ showKind1RepliesRef.current,
+ showKind1111Ref.current
+ )
+ )
+ }
+ if (!useFilterAsIsRef.current || !clientSideKindFilterRef.current) return evs
+ if (!withKindFilterRef.current) return evs
+ return evs.filter((e) => effectiveShowKindsRef.current.includes(e.kind))
+ }
+
/**
* When to apply kind picker + kind-1 OP|reply / 1111 / GitRelease splits to visible rows.
* Home feeds default to {@link withKindFilter}. Relay explorer sets {@link showAllKinds} explicitly (kindless
@@ -1436,88 +1447,6 @@ const NoteList = forwardRef(
}
}, [visibleNoteIdsForStatsPrefetchKey])
- const [feedVirtualScrollParent, setFeedVirtualScrollParent] = useState(null)
- const [feedVirtualScrollMarginTop, setFeedVirtualScrollMarginTop] = useState(0)
- /** Last applied scroll port — skip redundant setState when RO fires on every row/media resize (fixes feed “shake”). */
- const lastFeedScrollPortRef = useRef<{ parent: HTMLElement | null; marginTop: number } | null>(null)
- /**
- * Resolve the scroll container once per feed / refresh — not on every {@link clientFilteredEvents} length tick.
- * Re-running this on each timeline merge re-set scroll state and interacted badly with the virtualizer while rows
- * were still settling (absolute rows could paint past the list bounds).
- */
- useLayoutEffect(() => {
- let alive = true
- let resizeCoalesceRaf = 0
-
- const applyFeedScrollPort = () => {
- if (!alive) return
- const anchor = resolveFeedVirtualScrollAnchor(feedRootRef.current, feedListScrollAnchorRef.current)
- if (!anchor) {
- const last = lastFeedScrollPortRef.current
- if (!last || last.parent !== null || last.marginTop !== 0) {
- lastFeedScrollPortRef.current = { parent: null, marginTop: 0 }
- setFeedVirtualScrollParent(null)
- setFeedVirtualScrollMarginTop(0)
- }
- return
- }
- const layoutEl = primaryScrollAreaRef?.current ?? null
- const nextParent = resolvePrimaryFeedScrollPort(layoutEl, anchor)
- const nextMargin = Math.round(anchor.offsetTop)
- const last = lastFeedScrollPortRef.current
- if (last && last.parent === nextParent && last.marginTop === nextMargin) {
- return
- }
- lastFeedScrollPortRef.current = { parent: nextParent, marginTop: nextMargin }
- setFeedVirtualScrollParent(nextParent)
- setFeedVirtualScrollMarginTop(nextMargin)
- }
-
- lastFeedScrollPortRef.current = null
- applyFeedScrollPort()
- let innerRaf = 0
- const outerRaf = requestAnimationFrame(() => {
- if (!alive) return
- applyFeedScrollPort()
- innerRaf = requestAnimationFrame(() => {
- if (!alive) return
- applyFeedScrollPort()
- })
- })
- const deferTimer = window.setTimeout(() => {
- if (!alive) return
- applyFeedScrollPort()
- }, 0)
-
- const scheduleApplyFromResize = () => {
- if (!alive) return
- if (resizeCoalesceRaf) cancelAnimationFrame(resizeCoalesceRaf)
- resizeCoalesceRaf = requestAnimationFrame(() => {
- resizeCoalesceRaf = 0
- if (!alive) return
- applyFeedScrollPort()
- })
- }
-
- let ro: ResizeObserver | null = null
- const root = feedRootRef.current
- if (root && typeof ResizeObserver !== 'undefined') {
- ro = new ResizeObserver(() => {
- scheduleApplyFromResize()
- })
- ro.observe(root)
- }
-
- return () => {
- alive = false
- if (resizeCoalesceRaf) cancelAnimationFrame(resizeCoalesceRaf)
- cancelAnimationFrame(outerRaf)
- cancelAnimationFrame(innerRaf)
- window.clearTimeout(deferTimer)
- ro?.disconnect()
- }
- }, [timelineSubscriptionKey, refreshCount, primaryScrollAreaRef])
-
const clientFilteredNewEvents = useMemo(
() =>
showFeedClientFilter ? applyClientFeedFilter(filteredNewEvents) : filteredNewEvents,
@@ -1657,10 +1586,52 @@ const NoteList = forwardRef(
}, 500)
}, [scrollToTop])
+ const flushPendingNewEventsIntoTimeline = useCallback(() => {
+ const pending = newEventsRef.current
+ if (pending.length === 0) return
+ setEvents((oldEvents) => {
+ const pool: Event[] = [...oldEvents]
+ const statsOnly: Event[] = []
+ const kept: Event[] = []
+ for (const ev of pending) {
+ if (
+ isNip18RepostKind(ev.kind) &&
+ feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(ev), pool)
+ ) {
+ statsOnly.push(ev)
+ continue
+ }
+ kept.push(ev)
+ pool.push(ev)
+ }
+ if (statsOnly.length > 0) {
+ noteStatsService.updateNoteStatsByEvents(statsOnly, undefined)
+ }
+ return [...kept, ...oldEvents]
+ })
+ setNewEvents([])
+ }, [])
+
+ const flushPendingNewEventsIntoTimelineRef = useRef(flushPendingNewEventsIntoTimeline)
+ flushPendingNewEventsIntoTimelineRef.current = flushPendingNewEventsIntoTimeline
+
+ useEffect(() => {
+ if (oneShotFetchRef.current) return
+ if (newEvents.length === 0) return
+ const anchor = feedRootRef.current
+ const parent = getNearestScrollableAncestor(anchor)
+ const root: HTMLElement | Window = parent ?? window
+ const top = root === window ? window.scrollY : (root as HTMLElement).scrollTop
+ if (top > AUTO_MERGE_NEW_EVENTS_TOP_PX) return
+ flushPendingNewEventsIntoTimeline()
+ }, [newEvents.length, flushPendingNewEventsIntoTimeline])
+
// Re-subscribe whenever connectivity flips so we immediately switch between
// local-only (offline) and normal (online) relay sets without waiting for
// the next user-triggered refresh.
const isOfflineRef = useRef(isOffline)
+ const oneShotFetchRef = useRef(oneShotFetch)
+ oneShotFetchRef.current = oneShotFetch
useEffect(() => {
const prev = isOfflineRef.current
isOfflineRef.current = isOffline
@@ -3010,6 +2981,10 @@ const NoteList = forwardRef(
eventsRef.current = events
}, [events])
+ useEffect(() => {
+ newEventsRef.current = newEvents
+ }, [newEvents])
+
const loadingSafetyMs = timelineLoadingSafetyTimeoutMs ?? 15_000
useEffect(() => {
@@ -3051,6 +3026,7 @@ const NoteList = forwardRef(
const blankFeedHiddenAtRef = useRef(null)
/** Avoid subscribe storms when the tab stays empty (dead relays): visibility resume used to call `refresh()` every few seconds. */
const blankFeedVisibilityResumeRetryAtRef = useRef(0)
+ const lastNewNotesAutoFlushMsRef = useRef(0)
useEffect(() => {
showCountRef.current = showCount
@@ -3128,6 +3104,86 @@ const NoteList = forwardRef(
oneShotFetch,
t
])
+
+ useEffect(() => {
+ if (!timelinePublicReadFallback) return
+ if (oneShotFetch || areAlgoRelays) return
+ if (!navigator.onLine) return
+ const warm = progressiveWarmupQuery?.trim()
+ if (warm) return
+ if (feedFullSearchEvents !== null) return
+ if (feedSubscribeRelayOutcomes.length === 0) return
+ if (publicReadFallbackAttemptedRef.current) return
+
+ const uiStatuses = relayOpTerminalRowsToTimelineRelayUiStatuses(feedSubscribeRelayOutcomes)
+ if (uiStatuses.some((s) => s.success)) return
+
+ publicReadFallbackAttemptedRef.current = true
+
+ const mapped = mapLiveSubRequestsForTimeline(subRequestsRef.current)
+ if (!mapped.length) return
+
+ const filter: Filter = { ...(mapped[0]!.filter as Filter) }
+ if (!filter.kinds?.length) {
+ filter.kinds = effectiveShowKinds.length > 0 ? [...effectiveShowKinds] : [kinds.ShortTextNote]
+ }
+ filter.limit = filter.limit ?? (areAlgoRelays ? ALGO_LIMIT : LIMIT)
+
+ const eventCap = allowKindlessRelayExplore
+ ? RELAY_EXPLORE_LIMIT
+ : areAlgoRelays
+ ? ALGO_LIMIT
+ : LIMIT
+
+ void (async () => {
+ try {
+ const raw = await client.fetchEvents(FAST_READ_RELAY_URLS, filter, {
+ cache: true,
+ globalTimeout: 22_000,
+ eoseTimeout: 3500,
+ firstRelayResultGraceMs: false
+ })
+ if (raw.length === 0) return
+
+ const narrowed = narrowLiveBatchUsingRefs(raw)
+ if (narrowed.length === 0) return
+
+ logger.info('[NoteList] Public read fallback merged after all relays failed', {
+ timelineSubscriptionKey,
+ fetched: raw.length,
+ mergedVisible: narrowed.length
+ })
+
+ setEvents((prev) => {
+ const next = progressiveWarmupQueryRef.current?.trim()
+ ? mergeProgressiveSearchEvents(
+ prev,
+ narrowed,
+ oneShotAfterMergeComparatorRef.current
+ )
+ : collapseDuplicateNip18RepostTimelineRows(
+ mergeEventBatchesById(prev, narrowed, eventCap, areAlgoRelays)
+ )
+ lastEventsForTimelinePrefetchRef.current = next
+ return next
+ })
+ feedRelayReturnedAnyEventRef.current = true
+ } catch (e) {
+ logger.warn('[NoteList] timeline public read fallback failed', { error: e })
+ }
+ })()
+ }, [
+ timelinePublicReadFallback,
+ oneShotFetch,
+ areAlgoRelays,
+ progressiveWarmupQuery,
+ feedFullSearchEvents,
+ feedSubscribeRelayOutcomes,
+ mapLiveSubRequestsForTimeline,
+ effectiveShowKinds,
+ allowKindlessRelayExplore,
+ timelineSubscriptionKey
+ ])
useEffect(() => {
hasMoreRef.current = hasMore
@@ -3400,6 +3456,20 @@ const NoteList = forwardRef(
let lastScrollTopForPrefetchDir = 0
let lastScrollPrefetchInvokeMs = 0
+ const onScrollFlushNewNotesAtTop = () => {
+ if (oneShotFetchRef.current) return
+ if (feedFullSearchEventsRef.current !== null) return
+ const t = scrollPrefetchTarget
+ if (!t) return
+ const top = t === window ? window.scrollY : (t as HTMLElement).scrollTop
+ if (top > AUTO_MERGE_NEW_EVENTS_TOP_PX) return
+ if (newEventsRef.current.length === 0) return
+ const now = Date.now()
+ if (now - lastNewNotesAutoFlushMsRef.current < 350) return
+ lastNewNotesAutoFlushMsRef.current = now
+ flushPendingNewEventsIntoTimelineRef.current()
+ }
+
const onScrollPrefetch = () => {
if (scrollPrefetchRafId) return
scrollPrefetchRafId = requestAnimationFrame(() => {
@@ -3434,17 +3504,18 @@ const NoteList = forwardRef(
}
const wireScrollPrefetch = () => {
- const anchor = resolveFeedVirtualScrollAnchor(feedRootRef.current, feedListScrollAnchorRef.current)
- const layoutEl = primaryScrollAreaRef?.current ?? null
- const parent = resolvePrimaryFeedScrollPort(layoutEl, anchor)
+ const anchor = feedRootRef.current
+ const parent = getNearestScrollableAncestor(anchor)
const next: HTMLElement | Window = parent ?? window
if (scrollPrefetchTarget && scrollPrefetchTarget !== next) {
scrollPrefetchTarget.removeEventListener('scroll', onScrollPrefetch)
+ scrollPrefetchTarget.removeEventListener('scroll', onScrollFlushNewNotesAtTop)
}
scrollPrefetchTarget = next
lastScrollTopForPrefetchDir =
next === window ? window.scrollY : (next as HTMLElement).scrollTop
next.addEventListener('scroll', onScrollPrefetch, { passive: true })
+ next.addEventListener('scroll', onScrollFlushNewNotesAtTop, { passive: true })
}
const wireScrollPrefetchSoonId = window.setTimeout(() => {
@@ -3474,6 +3545,7 @@ const NoteList = forwardRef(
window.clearTimeout(wireScrollPrefetchSoonId)
if (scrollPrefetchTarget) {
scrollPrefetchTarget.removeEventListener('scroll', onScrollPrefetch)
+ scrollPrefetchTarget.removeEventListener('scroll', onScrollFlushNewNotesAtTop)
scrollPrefetchTarget = null
}
if (observerInstance && currentBottomRef) {
@@ -3485,7 +3557,7 @@ const NoteList = forwardRef(
loadMoreTimeoutRef.current = null
}
}
- }, [timelineSubscriptionKey, primaryScrollAreaRef])
+ }, [timelineSubscriptionKey])
// CRITICAL: Prefetch embedded events (referenced in e tags, a tags, and content)
// This ensures embedded events are ready before user scrolls to them
@@ -3613,27 +3685,7 @@ const NoteList = forwardRef(
}, [events.length, showCount, loading, hasMore, mergePrefetchTargetsFromEvents])
const showNewEvents = () => {
- setEvents((oldEvents) => {
- const pool: Event[] = [...oldEvents]
- const statsOnly: Event[] = []
- const kept: Event[] = []
- for (const ev of newEvents) {
- if (
- isNip18RepostKind(ev.kind) &&
- feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(ev), pool)
- ) {
- statsOnly.push(ev)
- continue
- }
- kept.push(ev)
- pool.push(ev)
- }
- if (statsOnly.length > 0) {
- noteStatsService.updateNoteStatsByEvents(statsOnly, undefined)
- }
- return [...kept, ...oldEvents]
- })
- setNewEvents([])
+ flushPendingNewEventsIntoTimeline()
setTimeout(() => {
scrollToTop('smooth')
}, 0)
@@ -3901,18 +3953,23 @@ const NoteList = forwardRef(
{t('Feed full search empty')}
) : null}
- {clientFilteredEvents.length > 0 ? (
-
- ) : null}
+ {gridLayout ? (
+
+ {clientFilteredEvents.map((event) => (
+
+ ))}
+
+ ) : (
+ clientFilteredEvents.map((event) => (
+
+ ))
+ )}
{listSourceEvents.length === 0 &&
!feedFullSearchActive &&
(loading || (subRequests.length > 0 && !feedTimelineEmptyUiReady)) ? (
@@ -4009,9 +4066,7 @@ const NoteList = forwardRef(
) : null}
{showFeedClientFilter ? feedClientFilterBar : null}
-
- {list}
-
+ {list}
) : (
@@ -4025,9 +4080,7 @@ const NoteList = forwardRef(
) : null}
{showFeedClientFilter ? feedClientFilterBar : null}
-
- {list}
-
+ {list}
)}
diff --git a/src/contexts/primary-page-scroll-area-context.tsx b/src/contexts/primary-page-scroll-area-context.tsx
deleted file mode 100644
index 1ed906eb..00000000
--- a/src/contexts/primary-page-scroll-area-context.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import { createContext, useContext, type ReactNode, type RefObject } from 'react'
-
-const PrimaryPageScrollAreaRefContext = createContext | null>(null)
-
-/**
- * The desktop primary column’s main `overflow-y: auto` node (see {@link PrimaryPageLayout}).
- * Feeds use this so {@link VirtualizedFeedRows} observes the same scrollport the user actually scrolls.
- */
-export function PrimaryPageScrollAreaRefProvider({
- scrollAreaRef,
- children
-}: {
- scrollAreaRef: RefObject
- children: ReactNode
-}) {
- return (
-
- {children}
-
- )
-}
-
-export function usePrimaryPageScrollAreaRefOptional(): RefObject | null {
- return useContext(PrimaryPageScrollAreaRefContext)
-}
diff --git a/src/layouts/PrimaryPageLayout/index.tsx b/src/layouts/PrimaryPageLayout/index.tsx
index 1b2714e2..cd84af23 100644
--- a/src/layouts/PrimaryPageLayout/index.tsx
+++ b/src/layouts/PrimaryPageLayout/index.tsx
@@ -3,7 +3,6 @@ import ScrollToTopButton from '@/components/ScrollToTopButton'
import { ReadOnlySessionIndicator } from '@/components/ReadOnlySessionIndicator'
import { Titlebar } from '@/components/Titlebar'
import { usePrimaryPage } from '@/contexts/primary-page-context'
-import { PrimaryPageScrollAreaRefProvider } from '@/contexts/primary-page-scroll-area-context'
import type { TPrimaryPageName } from '@/PageManager'
import { DeepBrowsingProvider } from '@/providers/DeepBrowsingProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
@@ -158,10 +157,8 @@ const PrimaryPageLayout = forwardRef(
: 'absolute bottom-0 left-0 right-0 top-0 min-w-0 overflow-y-auto overflow-x-auto'
}
>
-
- {children}
-
-
+ {children}
+
{displayScrollToTopButton && }
diff --git a/src/lib/relay-list-builder.ts b/src/lib/relay-list-builder.ts
index 9ac59195..e13fae85 100644
--- a/src/lib/relay-list-builder.ts
+++ b/src/lib/relay-list-builder.ts
@@ -65,10 +65,10 @@ export interface RelayListBuilderOptions {
/** Whether to include user's favorite relays (kind 10012) */
includeFavoriteRelays?: boolean
/**
- * When true with fast-read / searchable includes: insert `FAST_READ_RELAY_URLS` and
- * `SEARCHABLE_RELAY_URLS` immediately after hints/seen/containing and **before** author + user
- * NIP-65 lists. Used for single-event / embed fetches so public mirrors (e.g. nos.lol) are not
- * queued behind dozens of personal relays under the global connection cap.
+ * When true with fast-read / searchable / profile-fetch includes: insert `PROFILE_FETCH_RELAY_URLS`,
+ * `FAST_READ_RELAY_URLS`, and `SEARCHABLE_RELAY_URLS` immediately after hints/seen/containing and **before**
+ * author + user NIP-65 lists. Used for batched metadata and embed fetches so public mirrors are not queued
+ * behind broken personal relays under the global connection cap.
*/
preferPublicReadRelaysEarly?: boolean
}
@@ -122,8 +122,12 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
// 3. Relays where containing event was found (for embedded events)
containingEventRelays.forEach(addRelay)
- // 3b. Public read / index relays before author + user NIP-65 expansion (embed + fetchEvent).
+ // 3b. Public profile / read relays before user favorites & NIP-65 (batched kind-0 — avoids burning
+ // connection slots on broken personal relays before PROFILE_FETCH + FAST_READ answer).
if (preferPublicReadRelaysEarly) {
+ if (includeProfileFetchRelays) {
+ PROFILE_FETCH_RELAY_URLS.forEach(addRelay)
+ }
if (includeFastReadRelays) {
FAST_READ_RELAY_URLS.forEach(addRelay)
}
diff --git a/src/pages/primary/NoteListPage/RelaysFeed.tsx b/src/pages/primary/NoteListPage/RelaysFeed.tsx
index 68150508..a2e32c1e 100644
--- a/src/pages/primary/NoteListPage/RelaysFeed.tsx
+++ b/src/pages/primary/NoteListPage/RelaysFeed.tsx
@@ -187,6 +187,10 @@ const RelaysFeed = forwardRef<
: undefined
}
feedTopNotice={feedTopNotice}
+ timelinePublicReadFallback={
+ feedInfo.feedType === 'all-favorites' ||
+ (feedInfo.feedType === 'relays' && relayUrls.length > 1)
+ }
/>
)
})
diff --git a/src/services/client-replaceable-events.service.ts b/src/services/client-replaceable-events.service.ts
index 64eacf64..69241c2e 100644
--- a/src/services/client-replaceable-events.service.ts
+++ b/src/services/client-replaceable-events.service.ts
@@ -537,7 +537,8 @@ export class ReplaceableEventService {
includeLocalRelays: true,
/** Many users publish kind 0 to NIP-65 write relays; batch path skipped these before. */
includeFastWriteRelays: true,
- includeSearchableRelays: false
+ includeSearchableRelays: false,
+ preferPublicReadRelaysEarly: true
})
} catch {
relayUrls = Array.from(new Set([...PROFILE_FETCH_RELAY_URLS, ...FAST_READ_RELAY_URLS]))
diff --git a/src/services/client.service.ts b/src/services/client.service.ts
index 68762a04..255e2e80 100644
--- a/src/services/client.service.ts
+++ b/src/services/client.service.ts
@@ -284,16 +284,6 @@ class ClientService extends EventTarget {
})
- /**
- * 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()
- /** Many shards / parallel REQs used to hit the strike threshold instantly on one dead relay; only one increment per window. */
- private sessionRelayFailureLastIncrementAt = new Map()
- public static readonly SESSION_RELAY_FAILURE_STRIKE_THRESHOLD = 4
- private static readonly SESSION_RELAY_FAILURE_INCREMENT_DEBOUNCE_MS = 12_000
-
/** Session-only: relay URL -> { successCount, sumLatencyMs } for preferring faster, proven relays when picking "random" relays. */
private sessionRelayPublishStats = new Map()
@@ -334,17 +324,8 @@ class ClientService extends EventTarget {
// Initialize sub-services
this.queryService = new QueryService(this.pool, {
- shouldSkipRelayForSession: (url) => {
- const key = canonicalRelayStrikeKey(url)
- if (!key) return false
- return (
- (this.publishStrikeCount.get(key) ?? 0) >=
- ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD
- )
- },
- onRelayConnectionFailure: (url) => this.recordSessionRelayFailure(url),
onRelayNoticeStrike: (normalizedUrl, noticeMessage) =>
- this.recordRelayNoticeFetchFailure(normalizedUrl, noticeMessage)
+ this.logRelayNoticeFetchFailure(normalizedUrl, noticeMessage)
})
this.eventService = new EventService(this.queryService)
this.replaceableEventService = new ReplaceableEventService(
@@ -1127,158 +1108,42 @@ class ClientService extends EventTarget {
return relays
}
- /** 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 notifySessionRelayStrikesChanged(affectedUrl?: string): void {
- if (typeof window === 'undefined') return
- window.dispatchEvent(
- new CustomEvent(JUMBLE_SESSION_RELAY_STRIKES_CHANGED, {
- detail: { url: affectedUrl }
- })
- )
- }
-
- /** Strikes accumulated this session for this relay (connection / NOTICE failures). */
- getSessionRelayStrikeCountForUrl(url: string): number {
+ /** NOTICE "failed to fetch events" — logged only (no session relay blocking). */
+ private logRelayNoticeFetchFailure(url: string, noticeMessage: string) {
const n = canonicalRelayStrikeKey(url)
- if (!n) return 0
- return this.publishStrikeCount.get(n) ?? 0
- }
-
- getSessionRelayFailureStrikeThreshold(): number {
- return ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD
- }
-
- /** True when this relay is skipped for reads/publishes until strikes are cleared. */
- isSessionRelayStrikedForReads(url: string): boolean {
- return this.getSessionRelayStrikeCountForUrl(url) >= this.getSessionRelayFailureStrikeThreshold()
- }
-
- private recordRelayNoticeFetchFailure(url: string, noticeMessage: string) {
- const n = canonicalRelayStrikeKey(url)
- if (!n) return
- const prev = this.publishStrikeCount.get(n) ?? 0
- if (prev >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) {
- return
- }
- logger.info('[Relay] NOTICE failed-fetch → session strike', {
- url: n,
+ logger.debug('[Relay] NOTICE failed-fetch', {
+ url: n ?? url,
noticeSnippet: noticeMessage.slice(0, 220)
})
- this.recordSessionRelayFailure(url)
}
- private recordSessionRelayFailure(url: string) {
- const n = canonicalRelayStrikeKey(url)
- if (!n) return
- if (isLocalNetworkUrl(n)) {
- return
- }
- const prev = this.publishStrikeCount.get(n) ?? 0
- if (prev >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) {
- return
- }
- const now = Date.now()
- const lastInc = this.sessionRelayFailureLastIncrementAt.get(n) ?? 0
- if (now - lastInc < ClientService.SESSION_RELAY_FAILURE_INCREMENT_DEBOUNCE_MS) {
- return
- }
- this.sessionRelayFailureLastIncrementAt.set(n, now)
- const count = prev + 1
- this.publishStrikeCount.set(n, count)
- if (count === ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) {
- logger.info('[Relay] Session strike threshold — relay skipped for reads/publishes until reload', {
- url: n,
- strikes: count
- })
- }
- this.notifySessionRelayStrikesChanged(n)
+ /** Legacy API: session strikes removed; always zero. */
+ getSessionRelayStrikeCountForUrl(_url: string): number {
+ return 0
}
- private filterSessionStrikedRelays(urls: string[]): string[] {
- return urls.filter((u) => {
- const n = canonicalRelayStrikeKey(u)
- if (!n) return true
- return (this.publishStrikeCount.get(n) ?? 0) < ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD
- })
+ getSessionRelayFailureStrikeThreshold(): number {
+ return 4
}
- /**
- * If every URL was session-striked, clear strikes once so reads/publishes can retry (mobile WebSocket churn).
- */
- clearSessionRelayStrikes(): void {
- if (this.publishStrikeCount.size === 0 && this.sessionRelayFailureLastIncrementAt.size === 0) return
- logger.info('[Relay] Session relay strikes cleared', { relayCount: this.publishStrikeCount.size })
- this.publishStrikeCount.clear()
- this.sessionRelayFailureLastIncrementAt.clear()
- this.notifySessionRelayStrikesChanged()
+ /** Legacy API: session strikes removed; relays are never skipped for reads for flaky connections. */
+ isSessionRelayStrikedForReads(_url: string): boolean {
+ return false
}
- /**
- * 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 = canonicalRelayStrikeKey(url)
- if (!n) return false
- const had = this.publishStrikeCount.delete(n)
- this.sessionRelayFailureLastIncrementAt.delete(n)
- if (had) {
- logger.info('[Relay] Session strikes cleared for relay (manual)', { url: n })
- this.notifySessionRelayStrikesChanged(n)
- }
- return had
+ /** No-op: use relay block list in settings instead of automatic session strikes. */
+ clearSessionRelayStrikes(): void {}
+
+ clearSessionRelayStrikeForUrl(_url: string): boolean {
+ return false
}
- /**
- * Clear session strikes for several URLs at once (e.g. publish relay picker). One UI notification.
- */
- clearSessionRelayStrikesForUrls(urls: string[]): number {
- let cleared = 0
- for (const url of urls) {
- const n = canonicalRelayStrikeKey(url)
- if (!n) continue
- if (this.publishStrikeCount.delete(n)) {
- cleared += 1
- this.sessionRelayFailureLastIncrementAt.delete(n)
- }
- }
- if (cleared > 0) {
- logger.info('[Relay] Session strikes cleared for relays (added to publish selection)', {
- cleared,
- urlCount: urls.length
- })
- this.notifySessionRelayStrikesChanged()
- }
- return cleared
+ clearSessionRelayStrikesForUrls(_urls: string[]): number {
+ return 0
}
- /**
- * 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
- * for every relay in the tab session.)
- */
private relayUrlsAfterStrikesOrRecover(urls: string[]): string[] {
- const unique = Array.from(new Set(urls))
- const filtered = this.filterSessionStrikedRelays(unique)
- if (filtered.length === 0 && unique.length > 0) {
- let cleared = 0
- for (const u of unique) {
- const n = canonicalRelayStrikeKey(u)
- if (n && this.publishStrikeCount.delete(n)) {
- cleared += 1
- this.sessionRelayFailureLastIncrementAt.delete(n)
- }
- }
- if (cleared === 0) return filtered
- logger.info('[Relay] Batch was all session-striked — cleared strikes for this batch only', {
- batchUrlCount: unique.length,
- strikeEntriesCleared: cleared
- })
- this.notifySessionRelayStrikesChanged()
- return this.filterSessionStrikedRelays(unique)
- }
- return filtered
+ return Array.from(new Set(urls))
}
/** Record a successful publish and its latency for session-based preference when selecting random relays. */
@@ -1305,7 +1170,6 @@ class ClientService extends EventTarget {
if (stats.successCount < 1) continue
const n = canonicalRelayStrikeKey(url)
if (!n || readOnlySet.has(n)) continue
- if ((this.publishStrikeCount.get(n) ?? 0) >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) continue
out.push(n)
}
out.sort((a, b) => {
@@ -1318,8 +1182,7 @@ class ClientService extends EventTarget {
}
/**
- * 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).
+ * Session-only debug for Settings: scored publish relays (no automatic session strikes).
*/
getSessionRelayDebug(): {
strikedUrls: string[]
@@ -1338,27 +1201,18 @@ class ClientService extends EventTarget {
if (n) presetSet.add(canonicalRelayStrikeKey(n))
}
const preset = Array.from(presetSet)
- const strikedUrls = Array.from(this.publishStrikeCount.entries())
- .filter(([, count]) => count >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD)
- .map(([url]) => url)
- const presetStriked = preset.filter(
- (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]) => ({
url,
successCount: s.successCount,
avgLatencyMs: Math.round(s.sumLatencyMs / s.successCount)
}))
scoredRelays.sort((a, b) => a.avgLatencyMs - b.avgLatencyMs)
- return { strikedUrls, scoredRelays, presetWorking, presetStriked }
+ return { strikedUrls: [], scoredRelays, presetWorking: preset, presetStriked: [] }
}
/**
* From a list of candidate relay URLs (e.g. public lively), return up to `count` relays,
- * preferring those that have succeeded and been fast this session. Excludes 3-strike and read-only relays.
+ * preferring those that have succeeded and been fast this session. Excludes read-only relays.
*/
getPreferredRelaysForRandom(candidateUrls: string[], count: number): string[] {
const readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeAnyRelayUrl(u) || u))
@@ -1366,14 +1220,9 @@ class ClientService extends EventTarget {
.map((u) => normalizeAnyRelayUrl(u) || u)
.filter((n) => n && !readOnlySet.has(n))
const unique = Array.from(new Set(normalizedCandidates))
- const notStruckOut = unique.filter((u) => {
- const n = canonicalRelayStrikeKey(u)
- if (!n) return false
- return (this.publishStrikeCount.get(n) ?? 0) < ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD
- })
const preferred: string[] = []
const rest: string[] = []
- for (const url of notStruckOut) {
+ for (const url of unique) {
const sk = canonicalRelayStrikeKey(url)
const stats = sk ? this.sessionRelayPublishStats.get(sk) : undefined
if (stats && stats.successCount >= 1) preferred.push(url)
@@ -1450,7 +1299,7 @@ class ClientService extends EventTarget {
finalContactedRelayCount: uniqueRelayUrls.length,
finalRelays: uniqueRelayUrls,
explain:
- 'Your NIP-65 write relays are prepended, then the list is de-duplicated, filtered (read-only / social-kind blocks / session strike skips), and capped at maxPublishRelays in outbox→inbox→favorite→fast-write priority. Unchecked relays in the picker are never contacted; checked relays beyond the cap or filtered out are also skipped.'
+ 'Your NIP-65 write relays are prepended, then the list is de-duplicated, filtered (read-only / social-kind blocks), and capped at maxPublishRelays in outbox→inbox→favorite→fast-write priority. Unchecked relays in the picker are never contacted; checked relays beyond the cap or filtered out are also skipped.'
})
}
@@ -1576,7 +1425,6 @@ class ClientService extends EventTarget {
if (!alreadyFinished) {
logger.warn('[PublishEvent] Marking relay as timed out', { url })
relayStatuses.push({ url, success: false, error: 'Timeout: Operation took too long' })
- client.recordSessionRelayFailure(url)
finishedCount++
}
})
@@ -1676,7 +1524,7 @@ class ClientService extends EventTarget {
logger.debug(`[PublishEvent] Relay connected`, { url })
const relayKeyPub = normalizeUrl(url) || url
patchRelayNoticeForFetchFailures(relay as unknown as AbstractRelay, relayKeyPub, (u, m) =>
- that.recordRelayNoticeFetchFailure(u, m)
+ that.logRelayNoticeFetchFailure(u, m)
)
applyRelayNip42AckTimeout(relay as unknown as AbstractRelay)
@@ -1722,13 +1570,11 @@ class ClientService extends EventTarget {
logger.error(`[PublishEvent] Auth or publish failed`, { url, error: authError.message })
errors.push({ url, error: authError })
relayStatuses.push({ url, success: false, error: authError.message })
- that.recordSessionRelayFailure(url)
})
} else {
logger.error(`[PublishEvent] Publish failed`, { url, error: error.message })
errors.push({ url, error })
relayStatuses.push({ url, success: false, error: error.message })
- that.recordSessionRelayFailure(url)
}
})
@@ -1786,7 +1632,6 @@ class ClientService extends EventTarget {
success: false,
error: error instanceof Error ? error.message : 'Connection failed'
})
- that.recordSessionRelayFailure(url)
} finally {
clearTimeout(relayTimeout)
const currentFinished = ++finishedCount
@@ -2417,10 +2262,9 @@ class ClientService extends EventTarget {
try {
relay = await that.pool.ensureRelay(url, { connectionTimeout: RELAY_POOL_CONNECTION_TIMEOUT_MS })
patchRelayNoticeForFetchFailures(relay, relayKey, (u, m) =>
- that.recordRelayNoticeFetchFailure(u, m)
+ that.logRelayNoticeFetchFailure(u, m)
)
} catch (err) {
- that.recordSessionRelayFailure(url)
that.queryService.releaseSubSlot(relayKey)
handleClose(i, (err as Error)?.message ?? String(err))
return
@@ -2472,11 +2316,10 @@ class ClientService extends EventTarget {
connectionTimeout: RELAY_POOL_CONNECTION_TIMEOUT_MS
})
patchRelayNoticeForFetchFailures(liveRelay, relayKey, (u, m) =>
- that.recordRelayNoticeFetchFailure(u, m)
+ that.logRelayNoticeFetchFailure(u, m)
)
} catch (err) {
nip42ResubscribePending.delete(i)
- that.recordSessionRelayFailure(url)
that.queryService.releaseSubSlot(relayKey)
handleClose(i, (err as Error)?.message ?? String(err))
return
@@ -3145,20 +2988,14 @@ class ClientService extends EventTarget {
return { events: [], connectionError: e instanceof Error ? e.message : String(e) }
}
}
- const usableAfterStrikes = this.relayUrlsAfterStrikesOrRecover([normalized])
- if (usableAfterStrikes.length === 0) {
- return { events: [], connectionError: 'Relay skipped this session (repeated failures)' }
- }
- const relayForConn = usableAfterStrikes[0]!
try {
- await this.pool.ensureRelay(relayForConn, { connectionTimeout: RELAY_POOL_CONNECTION_TIMEOUT_MS })
+ await this.pool.ensureRelay(normalized, { connectionTimeout: RELAY_POOL_CONNECTION_TIMEOUT_MS })
} catch (e) {
- this.recordSessionRelayFailure(relayForConn)
const msg = e instanceof Error ? e.message : String(e)
return { events: [], connectionError: msg }
}
try {
- const events = await this.queryService.query([relayForConn], filter, undefined, {
+ const events = await this.queryService.query([normalized], filter, undefined, {
globalTimeout: options?.globalTimeout ?? 25_000
})
return { events, connectionError: undefined }
diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts
index cf51c4c2..821f1692 100644
--- a/src/services/note-stats.service.ts
+++ b/src/services/note-stats.service.ts
@@ -94,11 +94,11 @@ class NoteStatsService {
private processBatchRunning = false
/** While greater than zero, {@link processBatch} defers so user publishes are not starved for WebSocket pool / bandwidth. */
private publishPriorityDepth = 0
- private readonly BATCH_DELAY = 200
- /** Small slices so a slow batch does not block newer cards (e.g. spell feed swaps placeholder rows → discussions). */
- private readonly MAX_BATCH_SIZE = 8
- /** Avoid 20+ simultaneous stats REQs (relay strikes / hangs); each slice runs in waves. */
- private readonly STATS_SLICE_CONCURRENCY = 4
+ private readonly BATCH_DELAY = 120
+ /** Larger slices: feed cards each trigger a stats fetch; tiny slices left the tail of the feed starved. */
+ private readonly MAX_BATCH_SIZE = 20
+ /** Parallel stats REQs per slice (bounded by relay pool pressure). */
+ private readonly STATS_SLICE_CONCURRENCY = 6
/** Client-only RSS/Web thread roots are not on relays; use the event passed into {@link fetchNoteStats}. */
private pendingSyntheticRootById = new Map()
/** Root event from {@link fetchNoteStats} (feed/card already has it; avoids fetchEvent miss → no stats UI). */
@@ -161,7 +161,8 @@ class NoteStatsService {
if (this.processBatchRunning) {
return
}
- const backlogLarge = this.pendingEvents.size >= this.MAX_BATCH_SIZE
+ const backlogLarge =
+ this.pendingForeground.size + this.pendingEvents.size >= this.MAX_BATCH_SIZE
if (backlogLarge || foreground) {
if (this.batchTimeout) {
clearTimeout(this.batchTimeout)
@@ -262,7 +263,8 @@ class NoteStatsService {
}
private async processBatch() {
- if (this.publishPriorityDepth > 0) {
+ /** Defer only background fetches while the user is publishing; open note / `foreground` must not starve. */
+ if (this.publishPriorityDepth > 0 && this.pendingForeground.size === 0) {
if (this.batchTimeout) {
clearTimeout(this.batchTimeout)
}
@@ -514,8 +516,8 @@ class NoteStatsService {
event: Event,
replaceableCoordinate?: string
): { nonSocial: Filter[]; social: Filter[] } {
- const reactionLimit = 300
- const interactionLimit = 80
+ const reactionLimit = 500
+ const interactionLimit = 120
const nip18RepostKinds = [kinds.Repost, ExtendedKind.GENERIC_REPOST]
/** Synthetic RSS/Web parents are not on relays; `#e` on the fake id returns nothing. Use only URL-scoped filters. */
@@ -857,26 +859,32 @@ class NoteStatsService {
return emoji
}
- private addLikeByEvent(evt: Event, originalEventAuthor?: string, forcedTargetEventId?: string) {
- let targetEventId = forcedTargetEventId ?? getFirstHexEventIdFromETags(evt.tags)
- if (!targetEventId && evt.kind === kinds.Reaction) {
+ private reactionTargetHexForLike(evt: Event, forcedTargetEventId?: string): string | undefined {
+ const forced = forcedTargetEventId?.trim()
+ if (forced) return forced
+ const parentHex = getParentEventHexId(evt)
+ if (parentHex && /^[0-9a-f]{64}$/i.test(parentHex)) return parentHex
+ const firstE = getFirstHexEventIdFromETags(evt.tags)
+ if (firstE) return firstE
+ if (evt.kind === kinds.Reaction) {
const pageUrl = getReactionPageUrlFromRTags(evt)
if (pageUrl) {
- targetEventId = rssArticleStableEventId(canonicalizeRssArticleUrl(pageUrl))
+ return rssArticleStableEventId(canonicalizeRssArticleUrl(pageUrl))
}
}
- if (!targetEventId) return
- targetEventId = this.statsKey(targetEventId)
+ return undefined
+ }
+
+ private addLikeByEvent(evt: Event, _originalEventAuthor?: string, forcedTargetEventId?: string) {
+ const targetEventIdRaw = this.reactionTargetHexForLike(evt, forcedTargetEventId)
+ if (!targetEventIdRaw) return
+ const targetEventId = this.statsKey(targetEventIdRaw)
const old = this.noteStatsMap.get(targetEventId) || {}
const likeIdSet = old.likeIdSet || new Set()
const likes = old.likes || []
if (likeIdSet.has(evt.id)) return
- if (originalEventAuthor && originalEventAuthor === evt.pubkey) {
- return
- }
-
const emoji = this.reactionEmojiFromEvent(evt)
likeIdSet.add(evt.id)
@@ -888,7 +896,7 @@ class NoteStatsService {
/** NIP-25 kind 17 reactions to http(s) URLs; stats key matches synthetic RSS thread root id. */
private addLikeByExternalWebReactionEvent(
evt: Event,
- originalEventAuthor?: string,
+ _originalEventAuthor?: string,
forcedTargetEventId?: string
) {
const url = getWebExternalReactionTargetUrl(evt)
@@ -903,10 +911,6 @@ class NoteStatsService {
const likes = old.likes || []
if (likeIdSet.has(evt.id)) return
- if (originalEventAuthor && originalEventAuthor === evt.pubkey) {
- return
- }
-
const emoji = this.reactionEmojiFromEvent(evt)
likeIdSet.add(evt.id)