From b12356bfa3865677011f600fb8d615b8bd0ed982 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 15 May 2026 21:39:16 +0200 Subject: [PATCH] bug-fixes --- package-lock.json | 4 +- package.json | 2 +- src/components/NormalFeed/index.tsx | 27 ++++++---- src/components/NoteList/index.tsx | 32 +++++++++-- src/components/Relay/index.tsx | 29 +++++++--- src/services/client.service.ts | 84 +++++++++++++++++++---------- 6 files changed, 125 insertions(+), 53 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9c193b2d..16ba6b0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "imwald", - "version": "23.10.0", + "version": "23.10.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "imwald", - "version": "23.10.0", + "version": "23.10.1", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index 576b04b2..a218cbb8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "imwald", - "version": "23.10.0", + "version": "23.10.1", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "private": true, "type": "module", diff --git a/src/components/NormalFeed/index.tsx b/src/components/NormalFeed/index.tsx index 7c7b17bd..48f803e0 100644 --- a/src/components/NormalFeed/index.tsx +++ b/src/components/NormalFeed/index.tsx @@ -16,7 +16,6 @@ import { forwardRef, useCallback, useEffect, - useLayoutEffect, useMemo, useRef, useState, @@ -121,6 +120,10 @@ const NormalFeed = forwardRef(function NormalFeed( { subRequests, @@ -156,7 +159,8 @@ const NormalFeed = forwardRef { + /** + * Push the tab row into {@link PrimaryPageLayout} subHeader. Use `useEffect` (not `useLayoutEffect`) so + * parent `setHomeSubHeader` runs after paint; synchronous layout updates here caused React #185 + * (maximum update depth) when navigating onto the home feed after other primaries (e.g. notifications). + * Intentionally omit `tabsElement` from deps — covered by `listMode` + `subHeaderFilterDepsKey`. + * Omit `onSubHeaderRefresh` / `onFeedFilterTabRowSlotRef`: only embedded in `tabsElement`; unstable + * identities there would retrigger every render and loop with parent state. + */ + useEffect(() => { if (!isMainFeed || !setSubHeader) return if (mergeFilterWithTabsRow) { setSubHeader( @@ -341,19 +352,14 @@ const NormalFeed = forwardRef setSubHeader(null) - // Intentionally omit `tabsElement`: same semantics are covered by listMode + subHeaderFilterDepsKey. - // Listing tabsElement here can retrigger the effect every render if its useMemo input references churn, - // which calls setSubHeader repeatedly → parent state → maximum update depth (#185). }, [ isMainFeed, setSubHeader, listMode, isWispTrendingOnlyFeed, subHeaderFilterDepsKey, - onSubHeaderRefresh, allowKindlessRelayExplore, - mergeFilterWithTabsRow, - onFeedFilterTabRowSlotRef + mergeFilterWithTabsRow ]) return ( @@ -414,6 +420,7 @@ const NormalFeed = forwardRef diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 53ad00b4..1629340d 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -772,6 +772,12 @@ const NoteList = forwardRef( * {@link client.fetchEvents} against {@link FAST_READ_RELAY_URLS} so the feed is not stuck on stale cache only. */ timelinePublicReadFallback = false, + /** + * Explore single-relay feed: paint only live events from that relay’s REQ — no session snapshot, no disk + * prime merge, no {@link ClientService.subscribeTimeline} IndexedDB hydrate / persist, no profile prefetch + * fan-out to other relays. + */ + relayAuthoritativeFeedOnly = false, /** * When set and the timeline is empty (after relays finish), show a link to Alexandria with a matching query * (hashtag / d-tag browse from {@link NormalFeed}). @@ -833,6 +839,8 @@ const NoteList = forwardRef( /** When true, render events as an Instagram-style 3-column square media grid. */ gridLayout?: boolean timelinePublicReadFallback?: boolean + /** Single-relay explore: only show events returned by that relay (no session/IDB/local merge). */ + relayAuthoritativeFeedOnly?: boolean /** Optional Alexandria `/events` URL when this feed’s timeline is empty (search / tag browse). */ alexandriaEmptyUrl?: string | null }, @@ -928,6 +936,8 @@ const NoteList = forwardRef( const [feedSubscribeRelayOutcomes, setFeedSubscribeRelayOutcomes] = useState([]) /** One-shot per timeline init: after an all-failed relay wave, try {@link FAST_READ_RELAY_URLS}. */ const publicReadFallbackAttemptedRef = useRef(false) + const relayAuthoritativeFeedOnlyRef = useRef(relayAuthoritativeFeedOnly) + relayAuthoritativeFeedOnlyRef.current = relayAuthoritativeFeedOnly /** * Bumped when {@link feedPaintLiveRelayDoneRef} becomes true so the empty-feed toast effect re-runs. * (Loading clears when subscribe wires; merged EOSE arrives later.) @@ -1893,6 +1903,7 @@ const NoteList = forwardRef( setLoading(true) let diskPrimeCancelled = false const primeDiskWhileAwaitingRelayProbe = async () => { + if (relayAuthoritativeFeedOnlyRef.current) return try { const mapped = stripNostrLandAggrFromTimelineSubRequests( feedSubscriptionKey, @@ -1974,7 +1985,9 @@ const NoteList = forwardRef( eventsRef.current.length > 0 const sessionSnap = - !userPulledRefresh ? getSessionFeedSnapshot(sessionSnapshotIdentityKey) : undefined + !userPulledRefresh && !relayAuthoritativeFeedOnlyRef.current + ? getSessionFeedSnapshot(sessionSnapshotIdentityKey) + : undefined const restoredFromSession = !keepExistingTimelineEvents && !!(sessionSnap?.length) const seeAllNoSpell = seeAllFeedEventsRef.current && !useFilterAsIsRef.current @@ -2074,6 +2087,7 @@ const NoteList = forwardRef( * {@link onEvents} so rows appear as soon as local sources resolve. */ const startNonBlockingTimelineDiskPrime = () => { + if (relayAuthoritativeFeedOnlyRef.current) return if (oneShotFetch || mappedSubRequests.length === 0) return if (isSpellPageLocalWarmup) return const diskReq = mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }> @@ -2802,6 +2816,7 @@ const NoteList = forwardRef( timelinePrefetchDebounceRef.current = setTimeout(() => { timelinePrefetchDebounceRef.current = null if (!effectActive) return + if (relayAuthoritativeFeedOnlyRef.current) return const evs = lastEventsForTimelinePrefetchRef.current if (evs.length === 0) return @@ -2997,6 +3012,7 @@ const NoteList = forwardRef( startLogin, needSort: !areAlgoRelays, firstRelayResultGraceMs: FIRST_RELAY_RESULT_GRACE_MS, + relayAuthoritativeTimeline: relayAuthoritativeFeedOnlyRef.current, onRelaySubscribeWaveComplete: (rows) => { if (!effectActive) return setFeedSubscribeRelayOutcomes(rows) @@ -3051,7 +3067,9 @@ const NoteList = forwardRef( setProgressiveLayersSearching(false) followingFeedDeltaCloserRef.current?.() followingFeedDeltaCloserRef.current = null - setSessionFeedSnapshot(snapshotKeyForCleanup, eventsRef.current) + if (!relayAuthoritativeFeedOnlyRef.current) { + setSessionFeedSnapshot(snapshotKeyForCleanup, eventsRef.current) + } if (kindlessEoseTimeoutRef.current) { clearTimeout(kindlessEoseTimeoutRef.current) kindlessEoseTimeoutRef.current = null @@ -3097,7 +3115,8 @@ const NoteList = forwardRef( onSingleRelayKindlessEmpty, mapLiveSubRequestsForTimeline, progressiveWarmupQuery, - hostPrimaryPageName + hostPrimaryPageName, + relayAuthoritativeFeedOnly ]) useEffect(() => { @@ -3316,7 +3335,8 @@ const NoteList = forwardRef( { startLogin, needSort: !areAlgoRelays, - firstRelayResultGraceMs: FIRST_RELAY_RESULT_GRACE_MS + firstRelayResultGraceMs: FIRST_RELAY_RESULT_GRACE_MS, + relayAuthoritativeTimeline: relayAuthoritativeFeedOnlyRef.current } ) if (!deltaActive) { @@ -3522,6 +3542,7 @@ const NoteList = forwardRef( ]) useEffect(() => { + if (relayAuthoritativeFeedOnly) return if (!timelinePublicReadFallback) return if (feedSubscriptionKey === 'home-all-favorites') return if (oneShotFetch || areAlgoRelays) return @@ -3609,7 +3630,8 @@ const NoteList = forwardRef( mapLiveSubRequestsForTimeline, effectiveShowKinds, allowKindlessRelayExplore, - timelineSubscriptionKey + timelineSubscriptionKey, + relayAuthoritativeFeedOnly ]) useEffect(() => { diff --git a/src/components/Relay/index.tsx b/src/components/Relay/index.tsx index 69e029a3..bf05db70 100644 --- a/src/components/Relay/index.tsx +++ b/src/components/Relay/index.tsx @@ -7,9 +7,10 @@ import type { TPrimaryPageName } from '@/PageManager' import { SINGLE_RELAY_KINDLESS_REQ_LIMIT } from '@/constants' import { isLocalNetworkUrl, normalizeAnyRelayUrl } from '@/lib/url' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' +import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' import client from '@/services/client.service' import type { TFeedSubRequest } from '@/types' -import type { Event } from 'nostr-tools' +import { kinds, type Event } from 'nostr-tools' import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import NotFound from '../NotFound' @@ -20,6 +21,7 @@ const Relay = forwardRef< >(function Relay({ url, className, hostPrimaryPageName }, ref) { const { t } = useTranslation() const { addRelayUrls, removeRelayUrls } = useCurrentRelays() + const { showKinds } = useKindFilterOrDefaults() const normalizedUrl = useMemo(() => (url ? normalizeAnyRelayUrl(url) : undefined), [url]) const { relayInfo } = useFetchRelayInfo(normalizedUrl) const [searchInput, setSearchInput] = useState('') @@ -66,18 +68,32 @@ const Relay = forwardRef< } }, [normalizedUrl, noteListRef]) + /** Default browse: explicit kinds (many strfry / small relays never return a useful kindless global REQ). */ + const relayBrowseKinds = useMemo( + () => (showKinds.length > 0 ? showKinds : [kinds.ShortTextNote]), + [showKinds] + ) + const relayFeedSubRequests = useMemo(() => { if (!normalizedUrl) return [] const q = debouncedInput.trim() + if (q) { + return [ + { + urls: [normalizedUrl], + filter: { search: q, limit: SINGLE_RELAY_KINDLESS_REQ_LIMIT } + } + ] + } return [ { urls: [normalizedUrl], - filter: q - ? { search: q, limit: SINGLE_RELAY_KINDLESS_REQ_LIMIT } - : { limit: SINGLE_RELAY_KINDLESS_REQ_LIMIT } + filter: { kinds: [...relayBrowseKinds], limit: SINGLE_RELAY_KINDLESS_REQ_LIMIT } } ] - }, [normalizedUrl, debouncedInput]) + }, [normalizedUrl, debouncedInput, relayBrowseKinds]) + + const allowKindlessRelayExplore = debouncedInput.trim().length > 0 /** When we know delivery relays, drop rows that never arrived from this feed’s relay (stale cache / mis-tagged). */ const relaySeenMatchKey = useMemo( @@ -117,12 +133,13 @@ const Relay = forwardRef< ref={noteListRef} subRequests={relayFeedSubRequests} useFilterAsIs - allowKindlessRelayExplore + allowKindlessRelayExplore={allowKindlessRelayExplore} showAllKinds showFeedClientFilter hostPrimaryPageName={hostPrimaryPageName} extraShouldHideEvent={shouldHideEventNotFromThisRelay} extraShouldHideRepliesEvent={shouldHideEventNotFromThisRelay} + relayAuthoritativeFeedOnly /> ) diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 1a72014b..32085ea6 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -349,6 +349,8 @@ class ClientService extends EventTarget { refs: TTimelineRef[] filter: TSubRequestFilter urls: string[] + /** When true, skip writing this shard to IndexedDB via {@link scheduleTimelinePersist} (relay-authoritative feeds). */ + disablePersist?: boolean } | string[] | undefined @@ -2146,14 +2148,20 @@ class ClientService extends EventTarget { startLogin, needSort = true, firstRelayResultGraceMs = FIRST_RELAY_RESULT_GRACE_MS, - onRelaySubscribeWaveComplete + onRelaySubscribeWaveComplete, + relayAuthoritativeTimeline = false }: { startLogin?: () => void needSort?: boolean /** Passed to each shard’s {@link ClientService._subscribeTimeline}: 2s after first event completes initial load if EOSE is slower. */ firstRelayResultGraceMs?: number /** After every timeline shard’s REQ wave has ended (per-relay EOSE / close / timeout), merged rows in shard order. */ - onRelaySubscribeWaveComplete?: (rows: RelayOpTerminalRow[]) => void + onRelaySubscribeWaveComplete?: (rows: RelayOpTerminalRow[]) => void, + /** + * Single-relay “what this relay stores” feeds: skip IndexedDB + session snapshot hydrate before the live REQ, + * skip persisting this shard, and do not widen an empty shard to {@link FAST_READ_RELAY_URLS}. + */ + relayAuthoritativeTimeline?: boolean } = {} ) { const timelineBatchId = `tl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}` @@ -2296,6 +2304,7 @@ class ClientService extends EventTarget { startLogin, needSort, firstRelayResultGraceMs, + relayAuthoritativeTimeline, relayReqLog: { groupId: `${timelineBatchId}:shard${shardIndex}`, onBatchEnd: onShardSubscribeBatchEnd @@ -2778,13 +2787,16 @@ class ClientService extends EventTarget { * every slow/hung relay. Real EOSE still clears the timer and completes earlier if all relays finish first. */ firstRelayResultGraceMs = FIRST_RELAY_RESULT_GRACE_MS, - relayReqLog + relayReqLog, + relayAuthoritativeTimeline = false }: { startLogin?: () => void needSort?: boolean firstRelayResultGraceMs?: number /** Correlate {@link ClientService.subscribe} logs with a timeline shard */ - relayReqLog?: { groupId: string; onBatchEnd?: (rows: RelayOpTerminalRow[]) => void } + relayReqLog?: { groupId: string; onBatchEnd?: (rows: RelayOpTerminalRow[]) => void }, + /** See {@link ClientService.subscribeTimeline} third-arg `relayAuthoritativeTimeline`. */ + relayAuthoritativeTimeline?: boolean } = {} ) { let relays = Array.from(new Set(urls)) @@ -2795,7 +2807,7 @@ class ClientService extends EventTarget { } if (relayFiltersUseCapitalLetterTagKeys(filter as Filter)) { relays = relayUrlsStripExtendedTagReqBlocked(relays) - if (relays.length === 0 && navigator.onLine) { + if (relays.length === 0 && navigator.onLine && !relayAuthoritativeTimeline) { relays = relayUrlsStripExtendedTagReqBlocked([...FAST_READ_RELAY_URLS]) } } @@ -2806,7 +2818,8 @@ class ClientService extends EventTarget { this.timelines[key] = { refs: [], filter, - urls: relays + urls: relays, + ...(relayAuthoritativeTimeline ? { disablePersist: true as const } : {}) } timeline = this.timelines[key] } else { @@ -2817,10 +2830,18 @@ class ClientService extends EventTarget { timeline.filter = filter timeline.urls = relays timeline.refs = [] + if (relayAuthoritativeTimeline) { + timeline.disablePersist = true + } else { + delete timeline.disablePersist + } } // eslint-disable-next-line @typescript-eslint/no-this-alias const that = this + const maybePersistTimeline = () => { + if (!relayAuthoritativeTimeline) that.scheduleTimelinePersist(key) + } let events: NEvent[] = [] /** `null` until initial backlog is considered complete; then wall-clock unix at completion (for straggler vs live). */ let eosedAt: number | null = null @@ -2898,25 +2919,27 @@ class ClientService extends EventTarget { } try { - const st = await indexedDb.getTimelinePersistedState(key) - if (st?.refs?.length) { - const hexIds = st.refs.map((r) => r[0]) - const list = await indexedDb.getArchivedEventsByIds(hexIds) - for (const ev of list) { - if (shouldDropEventOnIngest(ev)) continue - if (eventIds.has(ev.id)) continue - eventIds.add(ev.id) - events.push(ev) - } - for (const refId of hexIds) { - if (eventIds.has(refId)) continue - const sess = that.eventService.peekSessionCachedEvent(refId) - if (sess && !shouldDropEventOnIngest(sess)) { - eventIds.add(refId) - events.push(sess) + if (!relayAuthoritativeTimeline) { + const st = await indexedDb.getTimelinePersistedState(key) + if (st?.refs?.length) { + const hexIds = st.refs.map((r) => r[0]) + const list = await indexedDb.getArchivedEventsByIds(hexIds) + for (const ev of list) { + if (shouldDropEventOnIngest(ev)) continue + if (eventIds.has(ev.id)) continue + eventIds.add(ev.id) + events.push(ev) } + for (const refId of hexIds) { + if (eventIds.has(refId)) continue + const sess = that.eventService.peekSessionCachedEvent(refId) + if (sess && !shouldDropEventOnIngest(sess)) { + eventIds.add(refId) + events.push(sess) + } + } + flushStreamingSnapshot() } - flushStreamingSnapshot() } } catch (err) { logger.warn('[ClientService] Timeline disk hydrate failed', err) @@ -2952,7 +2975,7 @@ class ClientService extends EventTarget { timeline.refs = events .map((e) => [e.id, e.created_at] as TTimelineRef) .sort((a, b) => b[1] - a[1]) - that.scheduleTimelinePersist(key) + maybePersistTimeline() } return } @@ -2967,7 +2990,7 @@ class ClientService extends EventTarget { if (timeline.refs.length === 0) { timeline.refs = events.map((e) => [e.id, e.created_at] as TTimelineRef).sort((a, b) => b[1] - a[1]) - that.scheduleTimelinePersist(key) + maybePersistTimeline() return } @@ -2983,7 +3006,7 @@ class ClientService extends EventTarget { } // idx === refs.length → strictly older than tail; splice appends (previous early-return dropped these). timeline.refs.splice(idx, 0, [evt.id, evt.created_at]) - that.scheduleTimelinePersist(key) + maybePersistTimeline() } const runHttpTimelinePollQuery = async (pollFilter: Filter) => { @@ -3045,7 +3068,8 @@ class ClientService extends EventTarget { that.timelines[key] = { refs: events.map((evt) => [evt.id, evt.created_at]), filter, - urls: relays + urls: relays, + ...(relayAuthoritativeTimeline ? { disablePersist: true as const } : {}) } } else if (tl.refs.length === 0) { tl.refs = events.map((evt) => [evt.id, evt.created_at] as TTimelineRef) @@ -3062,7 +3086,7 @@ class ClientService extends EventTarget { } armHttpTimelinePollingAfterInitial() onEvents([...events], true) - that.scheduleTimelinePersist(key) + maybePersistTimeline() } // HTTP index relays are handled via httpTimelinePollBases above — never pass them to the WS subscribe path. @@ -3163,7 +3187,9 @@ class ClientService extends EventTarget { timeline.refs.push(...newRefs) } - this.scheduleTimelinePersist(key) + if (!timeline.disablePersist) { + this.scheduleTimelinePersist(key) + } return events }