Browse Source

get rid of session blocking

imwald
Silberengel 1 month ago
parent
commit
b0c25d649b
  1. 38
      package-lock.json
  2. 3
      package.json
  3. 2
      src/components/NewNotesButton/index.tsx
  4. 6
      src/components/NormalFeed/index.tsx
  5. 4
      src/components/Note/index.tsx
  6. 167
      src/components/NoteList/VirtualizedFeedRows.tsx
  7. 361
      src/components/NoteList/index.tsx
  8. 25
      src/contexts/primary-page-scroll-area-context.tsx
  9. 7
      src/layouts/PrimaryPageLayout/index.tsx
  10. 14
      src/lib/relay-list-builder.ts
  11. 4
      src/pages/primary/NoteListPage/RelaysFeed.tsx
  12. 3
      src/services/client-replaceable-events.service.ts
  13. 225
      src/services/client.service.ts
  14. 52
      src/services/note-stats.service.ts

38
package-lock.json generated

@ -1,12 +1,12 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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",

3
package.json

@ -1,6 +1,6 @@ @@ -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 @@ @@ -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",

2
src/components/NewNotesButton/index.tsx

@ -32,7 +32,7 @@ export default function NewNotesButton({ @@ -32,7 +32,7 @@ export default function NewNotesButton({
{newEvents.length > 0 && (
<div
className={cn(
'w-full flex justify-center z-40 pointer-events-none',
'w-full flex justify-center z-[100] pointer-events-none',
isSmallScreen ? 'fixed' : 'absolute bottom-6'
)}
style={isSmallScreen ? { bottom: 'calc(4rem + env(safe-area-inset-bottom))' } : undefined}

6
src/components/NormalFeed/index.tsx

@ -78,6 +78,8 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -78,6 +78,8 @@ const NormalFeed = forwardRef<TNoteListRef, {
extraShouldHideEvent?: (ev: Event) => 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<TNoteListRef, { @@ -106,7 +108,8 @@ const NormalFeed = forwardRef<TNoteListRef, {
progressiveDocumentKinds,
oneShotAfterMergeComparator,
extraShouldHideEvent,
oneShotMergedCap
oneShotMergedCap,
timelinePublicReadFallback = false
},
ref
) {
@ -331,6 +334,7 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -331,6 +334,7 @@ const NormalFeed = forwardRef<TNoteListRef, {
oneShotAfterMergeComparator={oneShotAfterMergeComparator}
extraShouldHideEvent={extraShouldHideEvent}
oneShotMergedCap={oneShotMergedCap}
timelinePublicReadFallback={timelinePublicReadFallback}
/>
</div>
</>

4
src/components/Note/index.tsx

@ -478,7 +478,7 @@ export default function Note({ @@ -478,7 +478,7 @@ export default function Note({
userId={event.pubkey}
size={size === 'small' ? 'medium' : 'normal'}
maxFileSizeKb={showFull ? 2048 : 500}
deferRemoteAvatar={!showFull}
deferRemoteAvatar={false}
/>
<div className="flex min-w-0 flex-1 flex-nowrap items-center gap-2 overflow-hidden">
<Username
@ -530,7 +530,7 @@ export default function Note({ @@ -530,7 +530,7 @@ export default function Note({
userId={event.pubkey}
size={size === 'small' ? 'medium' : 'normal'}
maxFileSizeKb={showFull ? 2048 : 500}
deferRemoteAvatar={!showFull}
deferRemoteAvatar={false}
/>
<div className="flex-1 w-0">
<div className="flex gap-2 items-center">

167
src/components/NoteList/VirtualizedFeedRows.tsx

@ -1,167 +0,0 @@ @@ -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<string, string>
/** 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<VirtualizedFeedRowsProps, 'useWindowScroll' | 'scrollElement'>) {
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 (
<div
className="relative isolate min-h-0 w-full overflow-x-hidden [contain:layout]"
style={{ height: virtualizer.getTotalSize() }}
>
{virtualizer.getVirtualItems().map((vi) => (
<div
key={vi.key}
data-index={vi.index}
ref={virtualizer.measureElement}
className="absolute left-0 w-full"
style={{ top: vi.start }}
>
{gridLayout ? (
<div className="grid grid-cols-3 gap-0.5 pr-4">
{events.slice(vi.index * 3, vi.index * 3 + 3).map((event) => (
<MediaGridItem key={event.id} event={event} />
))}
</div>
) : (
<NoteCard
className="w-full"
event={events[vi.index]!}
filterMutedNotes={filterMutedNotes}
bottomNoteLabel={eventReasonLabelMap.get(events[vi.index]!.id)}
/>
)}
</div>
))}
</div>
)
})
const ElementRows = memo(function ElementRows({
events,
gridLayout,
filterMutedNotes,
eventReasonLabelMap,
scrollElement
}: Omit<VirtualizedFeedRowsProps, 'useWindowScroll' | 'scrollMarginTop'> & {
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 (
<div
className="relative isolate min-h-0 w-full overflow-x-hidden [contain:layout]"
style={{ height: virtualizer.getTotalSize() }}
>
{virtualizer.getVirtualItems().map((vi) => (
<div
key={vi.key}
data-index={vi.index}
ref={virtualizer.measureElement}
className="absolute left-0 w-full"
style={{ top: vi.start }}
>
{gridLayout ? (
<div className="grid grid-cols-3 gap-0.5 pr-4">
{events.slice(vi.index * 3, vi.index * 3 + 3).map((event) => (
<MediaGridItem key={event.id} event={event} />
))}
</div>
) : (
<NoteCard
className="w-full"
event={events[vi.index]!}
filterMutedNotes={filterMutedNotes}
bottomNoteLabel={eventReasonLabelMap.get(events[vi.index]!.id)}
/>
)}
</div>
))}
</div>
)
})
/** 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 (
<WindowRows
events={events}
gridLayout={gridLayout}
filterMutedNotes={filterMutedNotes}
eventReasonLabelMap={eventReasonLabelMap}
scrollMarginTop={scrollMarginTop}
/>
)
}
if (!scrollElement) {
return null
}
return (
<ElementRows
events={events}
gridLayout={gridLayout}
filterMutedNotes={filterMutedNotes}
eventReasonLabelMap={eventReasonLabelMap}
scrollElement={scrollElement}
/>
)
})

361
src/components/NoteList/index.tsx

@ -1,5 +1,11 @@ @@ -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' @@ -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 { @@ -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 @@ -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 @@ -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( @@ -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( @@ -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( @@ -778,6 +774,7 @@ const NoteList = forwardRef(
const feedFullSearchEventsRef = useRef<Event[] | null>(null)
const displayTimelineSourceRef = useRef<Event[]>([])
const [newEvents, setNewEvents] = useState<Event[]>([])
const newEventsRef = useRef<Event[]>([])
const [hasMore, setHasMore] = useState<boolean>(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( @@ -793,7 +790,6 @@ const NoteList = forwardRef(
const [feedClientTimeAmount, setFeedClientTimeAmount] = useState('')
const [feedClientTimeUnit, setFeedClientTimeUnit] = useState<TFeedClientTimeUnit>('day')
const supportTouch = useMemo(() => isTouchDevice(), [])
const primaryScrollAreaRef = usePrimaryPageScrollAreaRefOptional()
const timelineEventsForFilter = feedFullSearchEvents ?? events
@ -807,12 +803,6 @@ const NoteList = forwardRef( @@ -807,12 +803,6 @@ const NoteList = forwardRef(
const bottomRef = useRef<HTMLDivElement | null>(null)
/** List root for intersection / load-more wiring (outer NoteList shell). */
const feedRootRef = useRef<HTMLDivElement | null>(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<HTMLDivElement | null>(null)
const topRef = useRef<HTMLDivElement | null>(null)
const spellFeedFirstPaintLoggedKeyRef = useRef('')
const consecutiveEmptyRef = useRef(0) // Track consecutive empty results to prevent infinite retries
@ -853,6 +843,8 @@ const NoteList = forwardRef( @@ -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<RelayOpTerminalRow[]>([])
/** 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( @@ -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( @@ -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( @@ -1436,88 +1447,6 @@ const NoteList = forwardRef(
}
}, [visibleNoteIdsForStatsPrefetchKey])
const [feedVirtualScrollParent, setFeedVirtualScrollParent] = useState<HTMLElement | null>(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( @@ -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( @@ -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( @@ -3051,6 +3026,7 @@ const NoteList = forwardRef(
const blankFeedHiddenAtRef = useRef<number | null>(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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -3901,18 +3953,23 @@ const NoteList = forwardRef(
{t('Feed full search empty')}
</div>
) : null}
{clientFilteredEvents.length > 0 ? (
<VirtualizedFeedRows
key={`${timelineSubscriptionKey}@@${refreshCount}`}
events={clientFilteredEvents}
gridLayout={gridLayout}
filterMutedNotes={filterMutedNotes}
eventReasonLabelMap={eventReasonLabelMap}
useWindowScroll={feedVirtualScrollParent === null}
scrollElement={feedVirtualScrollParent}
scrollMarginTop={feedVirtualScrollMarginTop}
/>
) : null}
{gridLayout ? (
<div className="grid grid-cols-3 gap-0.5 pr-4">
{clientFilteredEvents.map((event) => (
<MediaGridItem key={event.id} event={event} />
))}
</div>
) : (
clientFilteredEvents.map((event) => (
<NoteCard
key={event.id}
className="w-full"
event={event}
filterMutedNotes={filterMutedNotes}
bottomNoteLabel={eventReasonLabelMap.get(event.id)}
/>
))
)}
{listSourceEvents.length === 0 &&
!feedFullSearchActive &&
(loading || (subRequests.length > 0 && !feedTimelineEmptyUiReady)) ? (
@ -4009,9 +4066,7 @@ const NoteList = forwardRef( @@ -4009,9 +4066,7 @@ const NoteList = forwardRef(
</div>
) : null}
{showFeedClientFilter ? feedClientFilterBar : null}
<div ref={feedListScrollAnchorRef} className="min-w-0 w-full">
{list}
</div>
{list}
</div>
</PullToRefresh>
) : (
@ -4025,9 +4080,7 @@ const NoteList = forwardRef( @@ -4025,9 +4080,7 @@ const NoteList = forwardRef(
</div>
) : null}
{showFeedClientFilter ? feedClientFilterBar : null}
<div ref={feedListScrollAnchorRef} className="min-w-0 w-full">
{list}
</div>
{list}
</div>
)}
</NoteFeedProfileContext.Provider>

25
src/contexts/primary-page-scroll-area-context.tsx

@ -1,25 +0,0 @@ @@ -1,25 +0,0 @@
import { createContext, useContext, type ReactNode, type RefObject } from 'react'
const PrimaryPageScrollAreaRefContext = createContext<RefObject<HTMLDivElement | null> | null>(null)
/**
* The desktop primary columns 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<HTMLDivElement | null>
children: ReactNode
}) {
return (
<PrimaryPageScrollAreaRefContext.Provider value={scrollAreaRef}>
{children}
</PrimaryPageScrollAreaRefContext.Provider>
)
}
export function usePrimaryPageScrollAreaRefOptional(): RefObject<HTMLDivElement | null> | null {
return useContext(PrimaryPageScrollAreaRefContext)
}

7
src/layouts/PrimaryPageLayout/index.tsx

@ -3,7 +3,6 @@ import ScrollToTopButton from '@/components/ScrollToTopButton' @@ -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( @@ -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'
}
>
<PrimaryPageScrollAreaRefProvider scrollAreaRef={scrollAreaRef}>
{children}
<div className="h-4" />
</PrimaryPageScrollAreaRefProvider>
{children}
<div className="h-4" />
</div>
</div>
{displayScrollToTopButton && <ScrollToTopButton scrollAreaRef={scrollAreaRef} />}

14
src/lib/relay-list-builder.ts

@ -65,10 +65,10 @@ export interface RelayListBuilderOptions { @@ -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 @@ -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)
}

4
src/pages/primary/NoteListPage/RelaysFeed.tsx

@ -187,6 +187,10 @@ const RelaysFeed = forwardRef< @@ -187,6 +187,10 @@ const RelaysFeed = forwardRef<
: undefined
}
feedTopNotice={feedTopNotice}
timelinePublicReadFallback={
feedInfo.feedType === 'all-favorites' ||
(feedInfo.feedType === 'relays' && relayUrls.length > 1)
}
/>
)
})

3
src/services/client-replaceable-events.service.ts

@ -537,7 +537,8 @@ export class ReplaceableEventService { @@ -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]))

225
src/services/client.service.ts

@ -284,16 +284,6 @@ class ClientService extends EventTarget { @@ -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<string, number>()
/** Many shards / parallel REQs used to hit the strike threshold instantly on one dead relay; only one increment per window. */
private sessionRelayFailureLastIncrementAt = new Map<string, number>()
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<string, { successCount: number; sumLatencyMs: number }>()
@ -334,17 +324,8 @@ class ClientService extends EventTarget { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 }

52
src/services/note-stats.service.ts

@ -94,11 +94,11 @@ class NoteStatsService { @@ -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<string, Event>()
/** Root event from {@link fetchNoteStats} (feed/card already has it; avoids fetchEvent miss → no stats UI). */
@ -161,7 +161,8 @@ class NoteStatsService { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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)

Loading…
Cancel
Save