Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
b12356bfa3
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 27
      src/components/NormalFeed/index.tsx
  4. 32
      src/components/NoteList/index.tsx
  5. 29
      src/components/Relay/index.tsx
  6. 84
      src/services/client.service.ts

4
package-lock.json generated

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

2
package.json

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

27
src/components/NormalFeed/index.tsx

@ -16,7 +16,6 @@ import { @@ -16,7 +16,6 @@ import {
forwardRef,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
@ -121,6 +120,10 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -121,6 +120,10 @@ const NormalFeed = forwardRef<TNoteListRef, {
timelinePublicReadFallback?: boolean
/** When the feed is empty and terminal, {@link NoteList} can show an Alexandria search link (hashtag / d-tag pages). */
alexandriaEmptyUrl?: string | null
/**
* Single-relay explore: only events from that relays live REQ (no session/IDB prime, no prefetch to other relays).
*/
relayAuthoritativeFeedOnly?: boolean
}>(function NormalFeed(
{
subRequests,
@ -156,7 +159,8 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -156,7 +159,8 @@ const NormalFeed = forwardRef<TNoteListRef, {
extraShouldHideGalleryEvent,
oneShotMergedCap,
timelinePublicReadFallback = false,
alexandriaEmptyUrl = null
alexandriaEmptyUrl = null,
relayAuthoritativeFeedOnly = false
},
ref
) {
@ -324,8 +328,15 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -324,8 +328,15 @@ const NormalFeed = forwardRef<TNoteListRef, {
const mergeFilterWithTabsRow =
showFeedClientFilter && ((isMainFeed && !!setSubHeader) || renderTabsInFeed)
/** Same row for multi-relay and single-relay chips: Notes/Replies + refresh + kind picker (REQ may stay kindless for single relay; NoteList filters client-side). */
useLayoutEffect(() => {
/**
* 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<TNoteListRef, { @@ -341,19 +352,14 @@ const NormalFeed = forwardRef<TNoteListRef, {
setSubHeader(tabsElement)
}
return () => 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<TNoteListRef, { @@ -414,6 +420,7 @@ const NormalFeed = forwardRef<TNoteListRef, {
oneShotMergedCap={oneShotMergedCap}
timelinePublicReadFallback={timelinePublicReadFallback && listMode === 'postsAndReplies'}
alexandriaEmptyUrl={alexandriaEmptyUrl}
relayAuthoritativeFeedOnly={relayAuthoritativeFeedOnly}
/>
</div>
</>

32
src/components/NoteList/index.tsx

@ -772,6 +772,12 @@ const NoteList = forwardRef( @@ -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 relays 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( @@ -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( @@ -928,6 +936,8 @@ const NoteList = forwardRef(
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)
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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -3097,7 +3115,8 @@ const NoteList = forwardRef(
onSingleRelayKindlessEmpty,
mapLiveSubRequestsForTimeline,
progressiveWarmupQuery,
hostPrimaryPageName
hostPrimaryPageName,
relayAuthoritativeFeedOnly
])
useEffect(() => {
@ -3316,7 +3335,8 @@ const NoteList = forwardRef( @@ -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( @@ -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( @@ -3609,7 +3630,8 @@ const NoteList = forwardRef(
mapLiveSubRequestsForTimeline,
effectiveShowKinds,
allowKindlessRelayExplore,
timelineSubscriptionKey
timelineSubscriptionKey,
relayAuthoritativeFeedOnly
])
useEffect(() => {

29
src/components/Relay/index.tsx

@ -7,9 +7,10 @@ import type { TPrimaryPageName } from '@/PageManager' @@ -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< @@ -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< @@ -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<TFeedSubRequest[]>(() => {
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< @@ -117,12 +133,13 @@ const Relay = forwardRef<
ref={noteListRef}
subRequests={relayFeedSubRequests}
useFilterAsIs
allowKindlessRelayExplore
allowKindlessRelayExplore={allowKindlessRelayExplore}
showAllKinds
showFeedClientFilter
hostPrimaryPageName={hostPrimaryPageName}
extraShouldHideEvent={shouldHideEventNotFromThisRelay}
extraShouldHideRepliesEvent={shouldHideEventNotFromThisRelay}
relayAuthoritativeFeedOnly
/>
</div>
)

84
src/services/client.service.ts

@ -349,6 +349,8 @@ class ClientService extends EventTarget { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -3163,7 +3187,9 @@ class ClientService extends EventTarget {
timeline.refs.push(...newRefs)
}
this.scheduleTimelinePersist(key)
if (!timeline.disablePersist) {
this.scheduleTimelinePersist(key)
}
return events
}

Loading…
Cancel
Save