Browse Source

bug-fixes and remove relay shards on landing

imwald
Silberengel 1 month ago
parent
commit
09ee209b07
  1. 408
      src/components/FavoriteRelaysFeedPicker/index.tsx
  2. 227
      src/components/NoteList/index.tsx
  3. 11
      src/hooks/useFetchProfile.tsx
  4. 7
      src/lib/translate-client.ts
  5. 71
      src/pages/primary/NoteListPage/RelaysFeed.tsx
  6. 26
      src/pages/primary/NoteListPage/index.tsx
  7. 63
      src/providers/FeedProvider.tsx
  8. 55
      src/services/client-replaceable-events.service.ts
  9. 8
      src/services/client.service.ts
  10. 88
      vite.config.ts

408
src/components/FavoriteRelaysFeedPicker/index.tsx

@ -1,408 +0,0 @@ @@ -1,408 +0,0 @@
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays'
import { getHttpRelayListFromEvent } from '@/lib/event-metadata'
import { toRelaySettings } from '@/lib/link'
import { normalizeAnyRelayUrl, normalizeUrl, simplifyUrl } from '@/lib/url'
import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay'
import { cn } from '@/lib/utils'
import { useContainerWidth } from '@/hooks/useContainerWidth'
import { useSecondaryPage } from '@/PageManager'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useFeed } from '@/providers/FeedProvider'
import { useNostr } from '@/providers/NostrProvider'
import { SquarePen } from 'lucide-react'
import { useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
/** Chips → dropdown below this container width (px). Matches Tailwind `sm` breakpoint. */
const NARROW_THRESHOLD = 640
const ALL_FAVORITES_VALUE = '__all_favorites__'
function relaySetToSelectValue(id: string) {
return `rs:${encodeURIComponent(id)}`
}
function selectValueToRelaySetId(v: string) {
if (!v.startsWith('rs:')) return null
return decodeURIComponent(v.slice(3))
}
/** Top-of-feed control: all favorites, Wisp trending (nostrarchives), relay sets, single relays, HTTP index relays. */
export default function FavoriteRelaysFeedPicker() {
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement>(null)
const containerWidth = useContainerWidth(containerRef)
// True when the component's own container is narrow — covers both mobile viewports
// and the left pane in double-pane desktop mode.
const isNarrow = containerWidth !== undefined ? containerWidth < NARROW_THRESHOLD : false
const { push } = useSecondaryPage()
const { favoriteRelays, blockedRelays, relaySets } = useFavoriteRelays()
const { feedInfo, switchFeed } = useFeed()
const { httpRelayListEvent } = useNostr()
const openFavoriteRelaySettings = () => {
push(toRelaySettings('favorite-relays'))
}
const settingsLabel = t('Relay settings')
const urls = useMemo(
() => getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays),
[favoriteRelays, blockedRelays]
)
/** HTTP index relay URLs from kind 10243, deduped, excluding any already in favorites. */
const httpRelayUrls = useMemo(() => {
if (!httpRelayListEvent) return []
const list = getHttpRelayListFromEvent(httpRelayListEvent)
const favKeys = new Set(urls.map((u) => normalizeAnyRelayUrl(u) || u))
const seen = new Set<string>()
const out: string[] = []
for (const u of [...list.httpRead, ...list.httpWrite]) {
const k = normalizeAnyRelayUrl(u) || u
if (!k || seen.has(k) || favKeys.has(k)) continue
seen.add(k)
out.push(k)
}
return out
}, [httpRelayListEvent, urls])
const wispTrendingRelayUrl = useMemo(() => buildWispTrendingNotesRelayUrl(), [])
const wispTrendingRelayKey = useMemo(
() => normalizeUrl(wispTrendingRelayUrl) || wispTrendingRelayUrl,
[wispTrendingRelayUrl]
)
const trendingUrlInFavoriteList = useMemo(
() => urls.some((u) => (normalizeUrl(u) || u) === wispTrendingRelayKey),
[urls, wispTrendingRelayKey]
)
// Use normalizeAnyRelayUrl so HTTP relay IDs are matched correctly (normalizeUrl converts http→ws).
const currentRelayKey =
feedInfo.feedType === 'relay' && feedInfo.id
? normalizeAnyRelayUrl(feedInfo.id) || feedInfo.id
: null
const allActive = feedInfo.feedType === 'all-favorites'
const trendingRelayActive =
feedInfo.feedType === 'relay' && currentRelayKey === wispTrendingRelayKey
const relaySetIdActive = feedInfo.feedType === 'relays' && feedInfo.id ? feedInfo.id : null
const orphanRelaySetId =
relaySetIdActive && !relaySets.some((s) => s.id === relaySetIdActive) ? relaySetIdActive : null
const selectValue = allActive
? ALL_FAVORITES_VALUE
: relaySetIdActive
? relaySetToSelectValue(relaySetIdActive)
: currentRelayKey
? currentRelayKey
: ALL_FAVORITES_VALUE
/** Values that exist in the mobile Select (for controlled `value` validation). */
const selectItems = useMemo(() => {
const items: { value: string }[] = [{ value: ALL_FAVORITES_VALUE }]
if (!trendingUrlInFavoriteList) {
items.push({ value: wispTrendingRelayKey })
}
for (const set of relaySets) {
items.push({ value: relaySetToSelectValue(set.id) })
}
if (orphanRelaySetId) {
items.push({ value: relaySetToSelectValue(orphanRelaySetId) })
}
for (const url of urls) {
items.push({ value: normalizeAnyRelayUrl(url) || url })
}
for (const url of httpRelayUrls) {
items.push({ value: normalizeAnyRelayUrl(url) || url })
}
if (
!allActive &&
feedInfo.feedType === 'relay' &&
feedInfo.id &&
!items.some((i) => i.value === currentRelayKey)
) {
items.push({ value: normalizeAnyRelayUrl(feedInfo.id) || feedInfo.id })
}
return items
}, [
urls,
httpRelayUrls,
allActive,
feedInfo.feedType,
feedInfo.id,
currentRelayKey,
relaySets,
orphanRelaySetId,
trendingUrlInFavoriteList,
wispTrendingRelayKey
])
const resolvedSelectValue = selectItems.some((i) => i.value === selectValue)
? selectValue
: ALL_FAVORITES_VALUE
const resolveRelayUrl = (value: string) => {
if (value === ALL_FAVORITES_VALUE) return null
const fromFav = urls.find((u) => (normalizeAnyRelayUrl(u) || u) === value)
if (fromFav) return fromFav
const fromHttp = httpRelayUrls.find((u) => (normalizeAnyRelayUrl(u) || u) === value)
if (fromHttp) return fromHttp
return value
}
const onPickValue = (v: string) => {
if (v === ALL_FAVORITES_VALUE) {
void switchFeed('all-favorites')
return
}
if (v === wispTrendingRelayKey) {
void switchFeed('relay', { relay: wispTrendingRelayUrl })
return
}
const setId = selectValueToRelaySetId(v)
if (setId) {
void switchFeed('relays', { activeRelaySetId: setId })
return
}
const relay = resolveRelayUrl(v)
if (relay) void switchFeed('relay', { relay })
}
if (urls.length === 0 && httpRelayUrls.length === 0 && relaySets.length === 0) return null
const editSettingsButton = (
<Button
type="button"
variant="outline"
size="icon"
className="h-9 w-9 shrink-0"
title={settingsLabel}
aria-label={settingsLabel}
onClick={(e) => {
e.stopPropagation()
openFavoriteRelaySettings()
}}
>
<SquarePen className="size-4" />
</Button>
)
if (isNarrow) {
return (
<div
ref={containerRef}
className="flex w-full min-w-0 items-center gap-1.5 border-b border-border/80 bg-background px-2 py-1.5"
aria-label={t('Favorite Relays')}
>
<div className="min-w-0 flex-1">
<Select value={resolvedSelectValue} onValueChange={onPickValue}>
<SelectTrigger className="h-9 w-full font-mono text-xs">
<SelectValue placeholder={t('Favorite Relays')} />
</SelectTrigger>
<SelectContent position="popper" className="z-[120] max-h-[min(24rem,70vh)]">
<SelectItem value={ALL_FAVORITES_VALUE} className="text-xs">
{t('All favorite relays')}
</SelectItem>
{!trendingUrlInFavoriteList ? (
<SelectItem value={wispTrendingRelayKey} className="text-xs font-sans">
{t('Trending on Nostr')}
</SelectItem>
) : null}
{relaySets.length > 0 || orphanRelaySetId ? (
<>
<SelectSeparator />
<SelectGroup>
<SelectLabel className="pl-2">{t('Relay sets')}</SelectLabel>
{relaySets.map((set) => (
<SelectItem
key={set.id}
value={relaySetToSelectValue(set.id)}
className="text-xs font-sans"
>
{set.name}
</SelectItem>
))}
{orphanRelaySetId ? (
<SelectItem
value={relaySetToSelectValue(orphanRelaySetId)}
className="font-mono text-xs"
>
{orphanRelaySetId}
</SelectItem>
) : null}
</SelectGroup>
</>
) : null}
{urls.length > 0 ? (
<>
{relaySets.length > 0 || orphanRelaySetId ? <SelectSeparator /> : null}
{urls.map((url) => {
const v = normalizeAnyRelayUrl(url) || url
return (
<SelectItem key={v} value={v} className="font-mono text-xs" title={url}>
{simplifyUrl(url)}
</SelectItem>
)
})}
</>
) : null}
{httpRelayUrls.length > 0 ? (
<>
<SelectSeparator />
<SelectGroup>
<SelectLabel className="pl-2">{t('HTTP relays')}</SelectLabel>
{httpRelayUrls.map((url) => {
const v = normalizeAnyRelayUrl(url) || url
return (
<SelectItem key={v} value={v} className="font-mono text-xs" title={url}>
{simplifyUrl(url)}
</SelectItem>
)
})}
</SelectGroup>
</>
) : null}
</SelectContent>
</Select>
</div>
{editSettingsButton}
</div>
)
}
return (
<div
ref={containerRef}
className="flex w-full min-w-0 items-center gap-1.5 border-b border-border/80 bg-background px-2 py-1.5"
role="toolbar"
aria-label={t('Favorite Relays')}
>
<div className="flex min-w-0 flex-1 gap-1.5 overflow-x-auto pb-0.5 scrollbar-hide [scrollbar-gutter:stable]">
<button
type="button"
className={cn(
'shrink-0 rounded-full border px-3 py-1 text-xs font-semibold transition-colors',
allActive
? 'border-primary bg-primary/15 text-foreground'
: 'border-border bg-muted/40 text-muted-foreground hover:bg-accent'
)}
onClick={() => void switchFeed('all-favorites')}
>
{t('All favorite relays')}
</button>
{!trendingUrlInFavoriteList ? (
<button
type="button"
className={cn(
'shrink-0 rounded-full border px-3 py-1 text-xs font-semibold transition-colors',
trendingRelayActive
? 'border-primary bg-primary/15 text-foreground'
: 'border-border bg-muted/40 text-muted-foreground hover:bg-accent'
)}
title={wispTrendingRelayUrl}
onClick={() => void switchFeed('relay', { relay: wispTrendingRelayUrl })}
>
{t('Trending on Nostr')}
</button>
) : null}
{(relaySets.length > 0 || orphanRelaySetId) && (
<div className="mx-0.5 shrink-0 self-stretch border-l border-border/80" aria-hidden />
)}
{relaySets.map((set) => {
const active = feedInfo.feedType === 'relays' && feedInfo.id === set.id
return (
<button
key={set.id}
type="button"
className={cn(
'max-w-[10rem] shrink-0 truncate rounded-full border px-3 py-1 text-xs font-semibold transition-colors',
active
? 'border-primary bg-primary/15 text-foreground'
: 'border-border bg-muted/40 text-muted-foreground hover:bg-accent'
)}
title={set.name}
onClick={() => void switchFeed('relays', { activeRelaySetId: set.id })}
>
{set.name}
</button>
)
})}
{orphanRelaySetId ? (
<button
type="button"
className={cn(
'max-w-[10rem] shrink-0 truncate rounded-full border px-3 py-1 font-mono text-xs font-semibold transition-colors',
'border-primary bg-primary/15 text-foreground'
)}
title={orphanRelaySetId}
onClick={() => void switchFeed('relays', { activeRelaySetId: orphanRelaySetId })}
>
{orphanRelaySetId}
</button>
) : null}
{urls.length > 0 && (relaySets.length > 0 || orphanRelaySetId) && (
<div className="mx-0.5 shrink-0 self-stretch border-l border-border/80" aria-hidden />
)}
{urls.map((url) => {
const key = normalizeAnyRelayUrl(url) || url
const active = feedInfo.feedType === 'relay' && currentRelayKey === key
return (
<button
key={key}
type="button"
className={cn(
'max-w-[11rem] shrink-0 truncate rounded-full border px-3 py-1 font-mono text-xs font-semibold transition-colors',
active
? 'border-primary bg-primary/15 text-foreground'
: 'border-border bg-muted/40 text-muted-foreground hover:bg-accent'
)}
title={url}
onClick={() => void switchFeed('relay', { relay: url })}
>
{simplifyUrl(url)}
</button>
)
})}
{httpRelayUrls.length > 0 && (
<div className="mx-0.5 shrink-0 self-stretch border-l border-border/80" aria-hidden />
)}
{httpRelayUrls.map((url) => {
const key = normalizeAnyRelayUrl(url) || url
const active = feedInfo.feedType === 'relay' && currentRelayKey === key
return (
<button
key={key}
type="button"
className={cn(
'max-w-[11rem] shrink-0 truncate rounded-full border px-3 py-1 font-mono text-xs font-semibold transition-colors',
active
? 'border-primary bg-primary/15 text-foreground'
: 'border-border bg-muted/40 text-muted-foreground hover:bg-accent'
)}
title={url}
onClick={() => void switchFeed('relay', { relay: url })}
>
{simplifyUrl(url)}
</button>
)
})}
</div>
{editSettingsButton}
</div>
)
}

227
src/components/NoteList/index.tsx

@ -134,7 +134,7 @@ const LOAD_MORE_SCROLL_PREFETCH_MIN_PX = 960 @@ -134,7 +134,7 @@ 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
const AUTO_MERGE_NEW_EVENTS_TOP_PX = 280
function getNearestScrollableAncestor(node: HTMLElement | null): HTMLElement | null {
if (!node) return null
@ -1019,6 +1019,11 @@ const NoteList = forwardRef( @@ -1019,6 +1019,11 @@ const NoteList = forwardRef(
/** Detect pull-to-refresh so preserve-mode feeds still clear; unrelated dep changes must not clear. */
const timelineEffectLastRefreshCountRef = useRef(refreshCount)
const followingFeedDeltaCloserRef = useRef<(() => void) | null>(null)
/**
* After `setEvents([])` / session restore / disk prime, React may not have flushed before the relay emits.
* Without this, `mergeEventBatchesById(prev, …)` can merge the new relay into the previous feed's rows.
*/
const timelineMergeBootstrapRef = useRef<Event[] | null>(null)
useLayoutEffect(() => {
publicReadFallbackAttemptedRef.current = false
@ -1132,6 +1137,8 @@ const NoteList = forwardRef( @@ -1132,6 +1137,8 @@ const NoteList = forwardRef(
showAllKindsRef.current = showAllKinds
const withKindFilterRef = useRef(withKindFilter)
withKindFilterRef.current = withKindFilter
const hostPrimaryPageNameRef = useRef(hostPrimaryPageName)
hostPrimaryPageNameRef.current = hostPrimaryPageName
const narrowLiveBatchUsingRefs = (evs: Event[]): Event[] => {
if (allowKindlessRelayExploreRef.current && showAllKindsRef.current) return evs
@ -1873,6 +1880,7 @@ const NoteList = forwardRef( @@ -1873,6 +1880,7 @@ const NoteList = forwardRef(
async function init() {
if (timelineEffectStale()) return undefined
timelineMergeBootstrapRef.current = null
feedPaintSessionPendingRef.current = false
feedPaintRelayPendingRef.current = false
feedPaintRelayMetaRef.current = null
@ -1954,10 +1962,65 @@ const NoteList = forwardRef( @@ -1954,10 +1962,65 @@ const NoteList = forwardRef(
? ALGO_LIMIT
: LIMIT
const isSpellPageLocalWarmup =
hostPrimaryPageName === 'spells' && !oneShotFetch && mappedSubRequests.length > 0
/**
* IndexedDB + session peek (inside {@link ClientService.getTimelineDiskSnapshotEvents}) without blocking
* relay REQ/subscribe. Merges the same way as live {@link onEvents} so rows appear as soon as disk resolves.
*/
const startNonBlockingTimelineDiskPrime = () => {
if (oneShotFetch || mappedSubRequests.length === 0) return
if (isSpellPageLocalWarmup) return
const diskReq = mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }>
void client
.getTimelineDiskSnapshotEvents(diskReq)
.then((diskRaw) => {
if (!effectActive || timelineEffectStale()) return
const diskNarrowed = narrowLiveBatch(diskRaw)
if (diskNarrowed.length === 0) return
setEvents((prev) => {
const boot = timelineMergeBootstrapRef.current
const base = boot !== null ? boot : prev
const next = progressiveWarmupQueryRef.current?.trim()
? mergeProgressiveSearchEvents(
base,
diskNarrowed,
oneShotAfterMergeComparatorRef.current
)
: collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(base, diskNarrowed, eventCapEarly, areAlgoRelays)
)
if (next.length > 0) {
timelineMergeBootstrapRef.current = next.slice()
}
lastEventsForTimelinePrefetchRef.current = next
return next
})
setNewEvents([])
setShowCount(revealBatchSize ?? SHOW_COUNT)
if (!feedPaintLiveRelayDoneRef.current) {
setLoading(false)
feedPaintRelayPendingRef.current = true
feedPaintRelayMetaRef.current = {
variant: 'disk_snapshot_async',
mergedCount: diskNarrowed.length
}
setFeedEmptyToastGateTick((n) => n + 1)
setFeedTimelineEmptyUiReady(true)
}
})
.catch(() => {
/* best-effort */
})
}
if (!keepExistingTimelineEvents) {
if (restoredFromSession && sessionSnap) {
feedPaintSessionPendingRef.current = true
const restored = collapseDuplicateNip18RepostTimelineRows(sessionSnap)
timelineMergeBootstrapRef.current = restored.slice()
setEvents(restored)
lastEventsForTimelinePrefetchRef.current = restored
setNewEvents([])
@ -1966,8 +2029,6 @@ const NoteList = forwardRef( @@ -1966,8 +2029,6 @@ const NoteList = forwardRef(
} else {
let primedFromDisk = false
let spellLocalMergeBase: Event[] = []
const isSpellPageLocalWarmup =
hostPrimaryPageName === 'spells' && !oneShotFetch && mappedSubRequests.length > 0
if (isSpellPageLocalWarmup) {
const shardFilters = mappedSubRequests.map(({ filter }) => filter as Filter)
@ -1997,6 +2058,7 @@ const NoteList = forwardRef( @@ -1997,6 +2058,7 @@ const NoteList = forwardRef(
)
if (mergedS.length > 0) {
spellLocalMergeBase = mergedS
timelineMergeBootstrapRef.current = mergedS.slice()
setEvents(mergedS)
lastEventsForTimelinePrefetchRef.current = mergedS
setNewEvents([])
@ -2012,6 +2074,7 @@ const NoteList = forwardRef( @@ -2012,6 +2074,7 @@ const NoteList = forwardRef(
}
}
void (async () => {
try {
const [diskRaw, fromPub, fromArch] = await Promise.all([
client.getTimelineDiskSnapshotEvents(
@ -2025,7 +2088,7 @@ const NoteList = forwardRef( @@ -2025,7 +2088,7 @@ const NoteList = forwardRef(
maxMatches: localLayerCap * 2
})
])
if (!timelineEffectStale()) {
if (!effectActive || timelineEffectStale()) return
const seen = new Set<string>()
const combinedRaw: Event[] = []
for (const ev of diskRaw) {
@ -2046,13 +2109,14 @@ const NoteList = forwardRef( @@ -2046,13 +2109,14 @@ const NoteList = forwardRef(
combinedRaw.push(ev)
}
combinedRaw.sort((a, b) => b.created_at - a.created_at)
if (combinedRaw.length > 0) {
if (combinedRaw.length === 0) return
const diskNarrowed = narrowLiveBatch(combinedRaw)
if (diskNarrowed.length > 0) {
if (diskNarrowed.length === 0) return
const merged = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(spellLocalMergeBase, diskNarrowed, eventCapEarly, areAlgoRelays)
)
if (merged.length > 0) {
if (merged.length === 0) return
timelineMergeBootstrapRef.current = merged.slice()
setEvents(merged)
lastEventsForTimelinePrefetchRef.current = merged
setNewEvents([])
@ -2064,44 +2128,14 @@ const NoteList = forwardRef( @@ -2064,44 +2128,14 @@ const NoteList = forwardRef(
spellLocalMergeBase.length > 0 ? 'spell_local_merged' : 'disk_snapshot',
mergedCount: merged.length
}
primedFromDisk = true
}
}
}
}
} catch {
/* spell local + disk snapshot is best-effort */
}
} else if (!oneShotFetch && mappedSubRequests.length > 0) {
try {
const diskRaw = await client.getTimelineDiskSnapshotEvents(
mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }>
)
if (!timelineEffectStale() && diskRaw.length > 0) {
const diskNarrowed = narrowLiveBatch(diskRaw)
if (diskNarrowed.length > 0) {
const merged = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById([], diskNarrowed, eventCapEarly, areAlgoRelays)
)
setEvents(merged)
lastEventsForTimelinePrefetchRef.current = merged
setNewEvents([])
setShowCount(revealBatchSize ?? SHOW_COUNT)
setLoading(false)
feedPaintRelayPendingRef.current = true
feedPaintRelayMetaRef.current = {
variant: 'disk_snapshot',
mergedCount: merged.length
}
primedFromDisk = true
}
}
} catch {
/* disk snapshot is best-effort */
}
})()
}
if (!primedFromDisk) {
if (!keepRowsVisible) setLoading(true)
timelineMergeBootstrapRef.current = []
setEvents([])
setNewEvents([])
setShowCount(revealBatchSize ?? SHOW_COUNT)
@ -2110,6 +2144,11 @@ const NoteList = forwardRef( @@ -2110,6 +2144,11 @@ const NoteList = forwardRef(
} else if (!keepRowsVisible) {
setLoading(true)
}
if (!oneShotFetch && mappedSubRequests.length > 0) {
startNonBlockingTimelineDiskPrime()
}
setHasMore(true)
consecutiveEmptyRef.current = 0 // Reset counter on refresh
@ -2138,18 +2177,22 @@ const NoteList = forwardRef( @@ -2138,18 +2177,22 @@ const NoteList = forwardRef(
return undefined
}
if (!warmQOneShot && mappedSubRequests.length > 0) {
try {
const diskRaw = await client.getTimelineDiskSnapshotEvents(
mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }>
)
if (!timelineEffectStale() && diskRaw.length > 0) {
const capDisk = oneShotMergedCap ?? ONE_SHOT_MERGED_CAP
const diskReqOneShot = mappedSubRequests as Array<{
urls: string[]
filter: TSubRequestFilter
}>
void client
.getTimelineDiskSnapshotEvents(diskReqOneShot)
.then((diskRaw) => {
if (!effectActive || timelineEffectStale()) return
if (diskRaw.length === 0) return
const narrowed = narrowLiveBatch(diskRaw)
if (narrowed.length > 0) {
if (narrowed.length === 0) return
const merged = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById([], narrowed, capDisk, areAlgoRelays)
)
if (merged.length > 0) {
if (merged.length === 0) return
setEvents(merged)
lastEventsForTimelinePrefetchRef.current = merged
setLoading(false)
@ -2159,12 +2202,10 @@ const NoteList = forwardRef( @@ -2159,12 +2202,10 @@ const NoteList = forwardRef(
variant: 'disk_snapshot_one_shot',
mergedCount: merged.length
}
}
}
}
} catch {
})
.catch(() => {
/* best-effort */
}
})
}
const firstRelayGraceResolved =
oneShotFirstRelayGraceMs === undefined
@ -2432,15 +2473,20 @@ const NoteList = forwardRef( @@ -2432,15 +2473,20 @@ const NoteList = forwardRef(
if (batch.length > 0) {
if (narrowed.length > 0) {
setEvents((prev) => {
const boot = timelineMergeBootstrapRef.current
const base = boot !== null ? boot : prev
const next = progressiveWarmupQueryRef.current?.trim()
? mergeProgressiveSearchEvents(
prev,
base,
narrowed,
oneShotAfterMergeComparatorRef.current
)
: collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(prev, narrowed, eventCap, areAlgoRelays)
mergeEventBatchesById(base, narrowed, eventCap, areAlgoRelays)
)
if (boot !== null && narrowed.length > 0) {
timelineMergeBootstrapRef.current = null
}
lastEventsForTimelinePrefetchRef.current = next
return next
})
@ -2538,6 +2584,10 @@ const NoteList = forwardRef( @@ -2538,6 +2584,10 @@ const NoteList = forwardRef(
) {
setFeedReasonLabelsTick((n) => n + 1)
}
if (eosed && timelineMergeBootstrapRef.current !== null) {
timelineMergeBootstrapRef.current = null
}
},
onNew: (event: Event) => {
if (!effectActive) return
@ -2580,15 +2630,52 @@ const NoteList = forwardRef( @@ -2580,15 +2630,52 @@ const NoteList = forwardRef(
if (shouldHideEventRef.current(event)) return
if (pubkey && event.pubkey === pubkey) {
setEvents((oldEvents) => {
if (oldEvents.some((e) => e.id === event.id)) return oldEvents
const boot = timelineMergeBootstrapRef.current
const base = boot !== null ? boot : oldEvents
if (base.some((e) => e.id === event.id)) {
return boot !== null ? base : oldEvents
}
if (
isNip18RepostKind(event.kind) &&
feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(event), oldEvents)
feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(event), base)
) {
noteStatsService.updateNoteStatsByEvents([event], undefined)
return oldEvents
return boot !== null ? base : oldEvents
}
return [event, ...oldEvents]
if (boot !== null) {
timelineMergeBootstrapRef.current = null
}
return [event, ...base]
})
} else if (hostPrimaryPageNameRef.current === 'feed') {
// Primary home relay feeds: merge live EVENTs into the timeline immediately. The generic path
// buffered everyone else's notes in `newEvents` until scroll-to-top — that felt like no streaming.
setEvents((oldEvents) => {
const boot = timelineMergeBootstrapRef.current
const base = boot !== null ? boot : oldEvents
if (base.some((e) => e.id === event.id)) {
return boot !== null ? base : oldEvents
}
if (
isNip18RepostKind(event.kind) &&
feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(event), base)
) {
noteStatsService.updateNoteStatsByEvents([event], undefined)
return boot !== null ? base : oldEvents
}
if (boot !== null) {
timelineMergeBootstrapRef.current = null
}
const cap = allowKindlessRelayExploreRef.current
? RELAY_EXPLORE_LIMIT
: areAlgoRelays
? ALGO_LIMIT
: LIMIT
const next = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(base, [event], cap, areAlgoRelays)
)
lastEventsForTimelinePrefetchRef.current = next
return next
})
} else {
setNewEvents((oldEvents) => {
@ -2659,6 +2746,7 @@ const NoteList = forwardRef( @@ -2659,6 +2746,7 @@ const NoteList = forwardRef(
const snapshotKeyForCleanup = sessionSnapshotIdentityKey
return () => {
effectActive = false
timelineMergeBootstrapRef.current = null
setProgressiveLayersSearching(false)
followingFeedDeltaCloserRef.current?.()
followingFeedDeltaCloserRef.current = null
@ -2883,6 +2971,22 @@ const NoteList = forwardRef( @@ -2883,6 +2971,22 @@ const NoteList = forwardRef(
}
return [event, ...oldEvents]
})
} else if (hostPrimaryPageNameRef.current === 'feed') {
setEvents((oldEvents) => {
if (oldEvents.some((e) => e.id === event.id)) return oldEvents
if (
isNip18RepostKind(event.kind) &&
feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(event), oldEvents)
) {
noteStatsService.updateNoteStatsByEvents([event], undefined)
return oldEvents
}
const next = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(oldEvents, [event], eventCapDelta, areAlgoRelays)
)
lastEventsForTimelinePrefetchRef.current = next
return next
})
} else {
setNewEvents((oldEvents) => {
const pool = [...eventsRef.current, ...oldEvents]
@ -3199,6 +3303,13 @@ const NoteList = forwardRef( @@ -3199,6 +3303,13 @@ const NoteList = forwardRef(
blankFeedHiddenAtRef.current = Date.now()
return
}
if (
!oneShotFetchRef.current &&
feedFullSearchEventsRef.current === null &&
newEventsRef.current.length > 0
) {
flushPendingNewEventsIntoTimelineRef.current()
}
const hidAt = blankFeedHiddenAtRef.current
blankFeedHiddenAtRef.current = null
const hiddenMs = hidAt != null ? Date.now() - hidAt : 0
@ -4045,7 +4156,7 @@ const NoteList = forwardRef( @@ -4045,7 +4156,7 @@ const NoteList = forwardRef(
)
return (
<div ref={feedRootRef}>
<div ref={feedRootRef} className="relative">
<div ref={topRef} className="scroll-mt-[calc(6rem+1px)]" />
<NoteFeedProfileContext.Provider value={noteFeedProfileContextValue}>
{supportTouch ? (

11
src/hooks/useFetchProfile.tsx

@ -496,11 +496,16 @@ export function useFetchProfile(id?: string, skipCache = false) { @@ -496,11 +496,16 @@ export function useFetchProfile(id?: string, skipCache = false) {
}
}
// CRITICAL: Guard against infinite loops - limit effect runs per pubkey (reduced from 10 to 3)
// Only increment if we're actually going to process (not early exiting)
// CRITICAL: Guard against infinite loops — limit effect runs per pubkey. Feed batch often leaves
// {@link batchPlaceholder} rows that need several retries across noteFeed.version bumps; use a higher cap.
if (extractedPubkey) {
const runCount = effectRunCountRef.current.get(extractedPubkey) || 0
if (runCount >= 3) {
const pkLower = extractedPubkey.toLowerCase()
const feedBatchPlaceholder =
noteFeed?.profiles.get(pkLower)?.batchPlaceholder === true ||
noteFeed?.profiles.get(extractedPubkey)?.batchPlaceholder === true
const maxRunsBeforeCircuitBreak = feedBatchPlaceholder ? 12 : 3
if (runCount >= maxRunsBeforeCircuitBreak) {
logger.warn('[useFetchProfile] Too many effect runs for this pubkey, preventing infinite loop', {
extractedPubkey,
runCount

7
src/lib/translate-client.ts

@ -154,8 +154,15 @@ export async function fetchTranslateLanguages(): Promise<TranslateLanguageOption @@ -154,8 +154,15 @@ export async function fetchTranslateLanguages(): Promise<TranslateLanguageOption
const t = Date.now()
if (t - lastLanguagesFailureLogAt > 10_000) {
lastLanguagesFailureLogAt = t
if (import.meta.env.DEV && (res.status === 503 || res.status === 502)) {
logger.debug(
'[Translate] /languages skipped — dev translate proxy has no backend (:5000). See PROXY_SETUP.md.',
{ status: res.status }
)
} else {
logger.warn('[Translate] /languages failed', { status: res.status })
}
}
languagesCache = { list: [], at: t, fromFailure: true }
recordAdvertisedTranslateCodesFromServer([])
return []

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

@ -1,18 +1,16 @@ @@ -1,18 +1,16 @@
import NormalFeed from '@/components/NormalFeed'
import type { TNoteListRef } from '@/components/NoteList'
import { SINGLE_RELAY_KINDLESS_REQ_LIMIT } from '@/constants'
import { checkAlgoRelay } from '@/lib/relay'
import {
isWispTrendingNotesRelayUrl,
WISP_TRENDING_FEED_KINDS
} from '@/lib/wisp-trending-relay'
import { normalizeUrl } from '@/lib/url'
import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import { useFeed } from '@/providers/FeedProvider'
import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider'
import relayInfoService from '@/services/relay-info.service'
import { kinds } from 'nostr-tools'
import React, { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import React, { forwardRef, useEffect, useMemo, useState } from 'react'
const RelaysFeed = forwardRef<
TNoteListRef,
@ -23,12 +21,9 @@ const RelaysFeed = forwardRef< @@ -23,12 +21,9 @@ const RelaysFeed = forwardRef<
kindsOverride?: number[]
}
>(function RelaysFeed({ setSubHeader, onSubHeaderRefresh, kindsOverride }, ref) {
const { t } = useTranslation()
const { feedInfo, relayUrls } = useFeed()
const { showKinds } = useKindFilterOrDefaults()
const [areAlgoRelays, setAreAlgoRelays] = useState(false)
/** After kindless single-relay REQ EOSEs with no events, re-subscribe with the normal kind list. */
const [singleRelayKindFallback, setSingleRelayKindFallback] = useState(false)
const relayUrlsKey = useMemo(
() =>
@ -92,11 +87,18 @@ const RelaysFeed = forwardRef< @@ -92,11 +87,18 @@ const RelaysFeed = forwardRef<
if (feedInfo.feedType === 'all-favorites') return 'all-favorites'
if (feedInfo.feedType === 'relays') return `relays:${feedInfo.id ?? ''}`
if (feedInfo.feedType === 'relay') {
const id = feedInfo.id ? normalizeUrl(feedInfo.id) || feedInfo.id : ''
/** Same canonical URL identity as {@link NoteList} `subRequestsKey` (not `normalizeUrl` alone — HTTP index relays differ). */
const urlsKey = [...relayUrls]
.map((u) => normalizeAnyRelayUrl(u) || u)
.filter(Boolean)
.sort()
.join('|')
if (urlsKey) return `relay:${urlsKey}`
const id = feedInfo.id ? normalizeAnyRelayUrl(feedInfo.id) || feedInfo.id : ''
return `relay:${id}`
}
return undefined
}, [feedInfo.feedType, feedInfo.id])
}, [feedInfo.feedType, feedInfo.id, relayUrls])
const wispTrendingSingleRelay =
feedInfo.feedType === 'relay' &&
@ -104,30 +106,6 @@ const RelaysFeed = forwardRef< @@ -104,30 +106,6 @@ const RelaysFeed = forwardRef<
!!relayUrls[0] &&
isWispTrendingNotesRelayUrl(relayUrls[0])
/** New relay chip / set: try kindless first again. */
useEffect(() => {
setSingleRelayKindFallback(false)
}, [feedTimelineScopeKey])
const onSingleRelayKindlessEmpty = useCallback(() => {
setSingleRelayKindFallback(true)
}, [])
/**
* One relay + user kind filter: kindless `{ limit }` REQ first (many relays error on huge `kinds` arrays).
* If that EOSEs with no events, `onSingleRelayKindlessEmpty` switches to explicit `kinds`.
*/
const singleRelayKindlessExplore =
feedInfo.feedType === 'relay' &&
relayUrls.length === 1 &&
!kindsOverride?.length &&
!singleRelayKindFallback &&
!wispTrendingSingleRelay
const feedTopNotice = singleRelayKindFallback ? (
<p className="leading-snug">{t('singleRelayKindFallbackNotice')}</p>
) : null
// Hooks must run every render — never place useMemo after conditional returns.
const subRequests = useMemo(() => {
if (!canRenderFeed) return []
@ -139,9 +117,6 @@ const RelaysFeed = forwardRef< @@ -139,9 +117,6 @@ const RelaysFeed = forwardRef<
}
]
}
if (singleRelayKindlessExplore) {
return [{ urls: relayUrls, filter: { limit: SINGLE_RELAY_KINDLESS_REQ_LIMIT } }]
}
return [
{
urls: relayUrls,
@ -150,14 +125,7 @@ const RelaysFeed = forwardRef< @@ -150,14 +125,7 @@ const RelaysFeed = forwardRef<
}
}
]
}, [
canRenderFeed,
relayUrls,
defaultKinds,
kindsOverride,
singleRelayKindlessExplore,
wispTrendingSingleRelay
])
}, [canRenderFeed, relayUrls, defaultKinds, wispTrendingSingleRelay])
if (!canRenderFeed) {
return null
@ -176,17 +144,14 @@ const RelaysFeed = forwardRef< @@ -176,17 +144,14 @@ const RelaysFeed = forwardRef<
onSubHeaderRefresh={onSubHeaderRefresh}
preserveTimelineOnSubRequestsChange
feedTimelineScopeKey={feedTimelineScopeKey}
useFilterAsIs={singleRelayKindlessExplore}
allowKindlessRelayExplore={singleRelayKindlessExplore}
clientSideKindFilter={singleRelayKindlessExplore}
showFeedClientFilter
hostPrimaryPageName="feed"
onSingleRelayKindlessEmpty={
feedInfo.feedType === 'relay' && relayUrls.length === 1 && !kindsOverride?.length
? onSingleRelayKindlessEmpty
: undefined
}
feedTopNotice={feedTopNotice}
/**
* {@link timelinePublicReadFallback} uses {@link FAST_READ_RELAY_URLS} with the shard filters kinds only
* there is no this relay URL scope. For a **single chip**, that made every relay show the same global batch.
* Keep fallback for multi-relay surfaces where a broad read matches user intent; single-chip feeds rely on
* that relay + disk/session hydrate only.
*/
timelinePublicReadFallback={
feedInfo.feedType === 'all-favorites' ||
(feedInfo.feedType === 'relays' && relayUrls.length > 1)

26
src/pages/primary/NoteListPage/index.tsx

@ -14,13 +14,11 @@ import React, { @@ -14,13 +14,11 @@ import React, {
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState
} from 'react'
import { useTranslation } from 'react-i18next'
import { FavoriteRelaysActiveStripMobileBar } from '@/components/FavoriteRelaysActiveStrip'
import FavoriteRelaysFeedPicker from '@/components/FavoriteRelaysFeedPicker'
import { ActiveRelaysTitlebarButton } from '@/components/ConnectedRelays/ActiveRelaysTitlebarButton'
import HelpAndAccountMenu from '@/components/HelpAndAccountMenu'
import Logo from '@/assets/Logo'
@ -37,9 +35,9 @@ const NoteListPage = forwardRef<TPageRef>((_, ref) => { @@ -37,9 +35,9 @@ const NoteListPage = forwardRef<TPageRef>((_, ref) => {
const [homeSubHeader, setHomeSubHeader] = useState<React.ReactNode>(null)
const usesSubHeader =
feedInfo.feedType === 'all-favorites' ||
feedInfo.feedType === 'relay' ||
feedInfo.feedType === 'relays' ||
feedInfo.feedType === 'all-favorites'
feedInfo.feedType === 'relays'
const runFeedRefresh = useCallback(() => {
feedRef.current?.refresh()
@ -105,19 +103,7 @@ const NoteListPage = forwardRef<TPageRef>((_, ref) => { @@ -105,19 +103,7 @@ const NoteListPage = forwardRef<TPageRef>((_, ref) => {
)
}
const showFavoriteRelaysPicker =
isReady &&
(feedInfo.feedType === 'all-favorites' ||
feedInfo.feedType === 'relay' ||
feedInfo.feedType === 'relays')
const feedPageTitle = useMemo(
() =>
feedInfo.feedType === 'relays'
? t('relayType_relay_set')
: t('Favorite Relays'),
[feedInfo.feedType, t]
)
const feedPageTitle = t('Favorite Relays')
const subHeader = (
<>
@ -125,16 +111,12 @@ const NoteListPage = forwardRef<TPageRef>((_, ref) => { @@ -125,16 +111,12 @@ const NoteListPage = forwardRef<TPageRef>((_, ref) => {
<div className="w-full min-w-0 border-b border-border/80 bg-background px-3 py-2 sm:px-4">
<h1 className="app-chrome-title leading-tight tracking-tight">{feedPageTitle}</h1>
</div>
{showFavoriteRelaysPicker ? <FavoriteRelaysFeedPicker /> : null}
{homeSubHeader}
</>
)
/** Desktop: nav/logo/account live in titlebar only on small screens; refresh moves to subheader when present. Omit empty h-12 strip. */
const showNoteListTitlebar =
isSmallScreen ||
!usesSubHeader ||
(feedInfo.feedType === 'relay' && !!feedInfo.id)
const showNoteListTitlebar = isSmallScreen || !usesSubHeader
return (
<PrimaryPageLayout

63
src/providers/FeedProvider.tsx

@ -1,4 +1,3 @@ @@ -1,4 +1,3 @@
import { DEFAULT_FAVORITE_RELAYS } from '@/constants'
import { getFavoritesFeedRelayUrls, mergeRelayUrlLayers } from '@/lib/favorites-feed-relays'
import { getRelaySetFromEvent, getRelayListFromEvent, getHttpRelayListFromEvent } from '@/lib/event-metadata'
import logger from '@/lib/logger'
@ -52,8 +51,7 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { @@ -52,8 +51,7 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
)
const [isReady, setIsReady] = useState(true)
const [feedInfo, setFeedInfo] = useState<TFeedInfo>({
feedType: 'relay',
id: DEFAULT_FAVORITE_RELAYS[0]
feedType: 'all-favorites'
})
const feedInfoRef = useRef<TFeedInfo>(feedInfo)
/** Same logical list as {@link mergeRelayUrlLayers} result — reuse array ref so NoteList does not re-subscribe. */
@ -204,61 +202,30 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { @@ -204,61 +202,30 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
logger.debug('FeedProvider: favoriteRelays is empty, using defaults')
}
const favoritesFeedRelays = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays)
let feedInfo: TFeedInfo = {
feedType: 'relay',
id: favoritesFeedRelays[0] ?? DEFAULT_FAVORITE_RELAYS[0]
}
// Ensure we always have a valid relay ID
if (!feedInfo.id) {
feedInfo.id = DEFAULT_FAVORITE_RELAYS[0]
}
logger.debug('Initial feedInfo setup:', { favoritesFeedRelays, favoriteRelays, blockedRelays, feedInfo })
let stored: TFeedInfo | null = null
if (pubkey) {
const storedFeedInfo = storage.getFeedInfo(pubkey)
logger.debug('Stored feed info:', storedFeedInfo)
if (storedFeedInfo) {
feedInfo = storedFeedInfo
}
const fromStorage = storage.getFeedInfo(pubkey)
logger.debug('Stored feed info:', fromStorage)
if (fromStorage) stored = fromStorage
}
// Pre-rewrite main feeds (`following`, `bookmarks`) are no longer supported; migrate persisted state.
const storedFeedType = (feedInfo as { feedType?: string }).feedType
const deprecatedMainFeed = storedFeedType === 'following' || storedFeedType === 'bookmarks'
if (deprecatedMainFeed) {
const previousMainFeed = storedFeedType
const storedFeedType = (stored as { feedType?: string } | null)?.feedType
const migrateHomeToCombo =
storedFeedType === 'following' ||
storedFeedType === 'bookmarks' ||
storedFeedType === 'relay' ||
storedFeedType === 'relays'
if (migrateHomeToCombo && pubkey) {
const migrated: TFeedInfo = { feedType: 'all-favorites' }
feedInfo = migrated
if (pubkey) {
storage.setFeedInfo(migrated, pubkey)
}
logger.info('[FeedProvider] Migrated deprecated feed type to all-favorites', {
previous: previousMainFeed
logger.info('[FeedProvider] Home feed uses combo (all-favorites); migrated stored selection', {
previous: storedFeedType
})
return await switchFeedRef.current('all-favorites')
}
if (feedInfo.feedType === 'relays') {
return await switchFeedRef.current('relays', { activeRelaySetId: feedInfo.id })
}
if (feedInfo.feedType === 'relay') {
// Check if the stored relay is blocked, if so use first visible relay instead
if (feedInfo.id && blockedRelays.includes(feedInfo.id)) {
logger.component('FeedProvider', 'Stored relay is blocked, using first visible relay instead')
feedInfo.id = favoritesFeedRelays[0] ?? DEFAULT_FAVORITE_RELAYS[0]
}
logger.component('FeedProvider', 'Initial relay setup, calling switchFeed', { relayId: feedInfo.id })
return await switchFeedRef.current('relay', { relay: feedInfo.id })
}
if (feedInfo.feedType === 'all-favorites') {
logger.debug('Initializing all-favorites feed')
return await switchFeedRef.current('all-favorites')
}
}
void init()
}, [pubkey, isInitialized, favoriteRelaysIdentity, blockedRelaysIdentity, relaySetsIdentity])

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

@ -1132,6 +1132,61 @@ export class ReplaceableEventService { @@ -1132,6 +1132,61 @@ export class ReplaceableEventService {
/* ignore */
}
}
/**
* Batched kind-0 REQ / DataLoader can miss rows that already exist in session or IndexedDB (ordering,
* timing, or chunk boundaries). Hydrate gaps from local caches first; only then hit the network.
*/
const gapIndices: number[] = []
for (let i = 0; i < deduped.length; i++) {
if (!events[i]) gapIndices.push(i)
}
const LOCAL_GAP_CHUNK = 16
for (let off = 0; off < gapIndices.length; off += LOCAL_GAP_CHUNK) {
const slice = gapIndices.slice(off, off + LOCAL_GAP_CHUNK)
await Promise.allSettled(
slice.map(async (idx) => {
const pubkey = deduped[idx]!
const pkLower = pubkey.toLowerCase()
let ev: NEvent | undefined = client.eventService.getSessionMetadataForPubkey(pkLower)
if (ev && shouldDropEventOnIngest(ev)) ev = undefined
if (!ev) {
try {
const row = await indexedDb.getReplaceableEvent(pkLower, kinds.Metadata)
if (row && !shouldDropEventOnIngest(row)) ev = row as NEvent
} catch {
/* ignore */
}
}
if (ev) events[idx] = ev
})
)
}
const MAX_METADATA_GAP_FILL_NETWORK = 48
const GAP_FILL_NETWORK_PARALLEL = 4
const stillGap: number[] = []
for (let i = 0; i < deduped.length; i++) {
if (!events[i]) stillGap.push(i)
}
const cappedNetwork = stillGap.slice(0, MAX_METADATA_GAP_FILL_NETWORK)
for (let off = 0; off < cappedNetwork.length; off += GAP_FILL_NETWORK_PARALLEL) {
const slice = cappedNetwork.slice(off, off + GAP_FILL_NETWORK_PARALLEL)
await Promise.allSettled(
slice.map(async (idx) => {
const pubkey = deduped[idx]!
try {
const ev = await this.fetchProfileEvent(pubkey, false)
if (ev && !shouldDropEventOnIngest(ev)) {
events[idx] = ev
}
} catch {
/* ignore */
}
})
)
}
const profiles: TProfile[] = []
for (let i = 0; i < deduped.length; i++) {
const ev = events[i]

8
src/services/client.service.ts

@ -2481,6 +2481,14 @@ class ClientService extends EventTarget { @@ -2481,6 +2481,14 @@ class ClientService extends EventTarget {
urls: relays
}
timeline = this.timelines[key]
} else {
// New subscription wave for this leaf key: the prior closer does not delete `timelines[key]`.
// Reusing stale `refs` made `handleTimelineEose` merge against an old head timestamp — e.g. when the
// relay sent a full `limit` batch whose newest row was still older than that head, `newRefs` became
// empty and `tl.refs` was replaced with [] or failed to adopt fresh rows (feed looked permanently stale).
timeline.filter = filter
timeline.urls = relays
timeline.refs = []
}
// eslint-disable-next-line @typescript-eslint/no-this-alias

88
vite.config.ts

@ -46,34 +46,73 @@ function fullReloadOnProvidersAndPages(): Plugin { @@ -46,34 +46,73 @@ function fullReloadOnProvidersAndPages(): Plugin {
}
/**
* Default proxy logs one multiline error + stack per failed request when the index relay is down.
* Throttle to one hint: match `/api/events` paths (dev-index-relay), not other proxies like `/sites`.
* `http-proxy` logs `Error: connect ECONNREFUSED …` via `console.error`, bypassing Vite's `logger.error`.
*/
function quietDevIndexRelayProxyErrors(devIndexRelayTarget: string): Plugin {
let lastSuppressedLog = 0
function isOptionalDevProxyConnRefusedNoise(args: unknown[]): boolean {
const blob = args
.map((a) => {
if (typeof a === 'string') return a
if (a instanceof Error) return `${a.message}\n${a.stack ?? ''}`
return ''
})
.join('\n')
if (!blob.includes('ECONNREFUSED')) return false
return (
blob.includes('127.0.0.1:5000') ||
blob.includes('127.0.0.1:8090') ||
/\b:5000\b/.test(blob) ||
/\b:8090\b/.test(blob)
)
}
/**
* When optional localhost backends are down, `http-proxy` otherwise logs a multiline stack per request.
* Throttle to one hint per category (cooldown), matching paths only real misconfigurations still log.
*/
function quietOptionalDevProxyErrors(devIndexRelayTarget: string): Plugin {
let lastIndexRelaySuppressed = 0
let lastTranslateSitesSuppressed = 0
const COOLDOWN_MS = 60_000
return {
name: 'quiet-dev-index-relay-proxy-errors',
name: 'quiet-optional-dev-proxy-errors',
apply: 'serve',
configureServer(server) {
const prevConsoleError = console.error.bind(console)
console.error = (...args: unknown[]) => {
if (isOptionalDevProxyConnRefusedNoise(args)) return
prevConsoleError(...args)
}
server.httpServer?.on('close', () => {
console.error = prevConsoleError
})
},
configResolved(config) {
const prevError = config.logger.error.bind(config.logger)
config.logger.error = (msg, options) => {
const text = typeof msg === 'string' ? msg : ''
if (
text.includes('http proxy error') &&
text.includes('ECONNREFUSED') &&
text.includes('/api/events')
) {
if (text.includes('http proxy error') && text.includes('ECONNREFUSED')) {
if (text.includes('/api/events') || text.includes('/dev-index-relay')) {
const now = Date.now()
if (now - lastSuppressedLog >= COOLDOWN_MS) {
lastSuppressedLog = now
if (now - lastIndexRelaySuppressed >= COOLDOWN_MS) {
lastIndexRelaySuppressed = now
config.logger.warn(
`[vite] Dev index relay not reachable (${devIndexRelayTarget}). Start it or set VITE_DEV_INDEX_RELAY_TARGET. Suppressing duplicate proxy errors for ${COOLDOWN_MS / 1000}s.`
)
}
return
}
if (text.includes('/api/translate') || text.includes('/sites')) {
const now = Date.now()
if (now - lastTranslateSitesSuppressed >= COOLDOWN_MS) {
lastTranslateSitesSuppressed = now
config.logger.warn(
`[vite] Optional dev proxies unreachable (LibreTranslate /api/translate → :5000, OG /sites → :8090). Start them or ignore — see PROXY_SETUP.md. Suppressing duplicate proxy errors for ${COOLDOWN_MS / 1000}s.`
)
}
return
}
}
prevError(msg, options)
}
}
@ -148,7 +187,28 @@ export default defineConfig(({ mode }) => { @@ -148,7 +187,28 @@ export default defineConfig(({ mode }) => {
'/api/translate': {
target: 'http://127.0.0.1:5000',
changeOrigin: true,
rewrite: (p) => p.replace(/^\/api\/translate/u, '') || '/'
rewrite: (p) => p.replace(/^\/api\/translate/u, '') || '/',
/** Match `/sites`: when LibreTranslate is not running, return JSON instead of a broken proxy response. */
configure(proxy) {
proxy.on('error', (_err, _req, res) => {
const r = res as {
headersSent?: boolean
writeHead?: (c: number, h: Record<string, string>) => void
end?: (b: string) => void
}
if (r.headersSent) return
if (typeof r?.writeHead === 'function' && typeof r?.end === 'function') {
r.writeHead(503, { 'Content-Type': 'application/json' })
r.end(
JSON.stringify({
ok: false,
error: 'translate_proxy_unreachable',
hint: 'Start LibreTranslate (or compatible API) on :5000 — see PROXY_SETUP.md'
})
)
}
})
}
},
'/sites': {
target: 'http://127.0.0.1:8090',
@ -352,7 +412,7 @@ export default defineConfig(({ mode }) => { @@ -352,7 +412,7 @@ export default defineConfig(({ mode }) => {
plugins: [
react(),
fullReloadOnProvidersAndPages(),
quietDevIndexRelayProxyErrors(devIndexRelayTarget),
quietOptionalDevProxyErrors(devIndexRelayTarget),
VitePWA({
registerType: 'autoUpdate',
// Use public/manifest.webmanifest and index.html <link> only; avoid duplicate manifest link in build

Loading…
Cancel
Save