diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index 9ab1b859..91b35815 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -133,23 +133,30 @@ function CodeBlock({ id, code, language }: { id: string; code: string; language: const codeRef = useRef(null) useEffect(() => { + let cancelled = false const initHighlight = async () => { - if (typeof window !== 'undefined' && codeRef.current) { - try { - const hljs = await import('highlight.js') - const codeElement = codeRef.current.querySelector('code') - if (codeElement) { - hljs.default.highlightElement(codeElement) - } - } catch (error) { + if (typeof window === 'undefined') return + try { + const hljs = await import('highlight.js') + if (cancelled) return + const root = codeRef.current + if (!root) return + const codeElement = root.querySelector('code') + if (codeElement) { + hljs.default.highlightElement(codeElement) + } + } catch (error) { + if (!cancelled) { logger.error('Error loading highlight.js:', error) } } } - - // Small delay to ensure DOM is ready - const timeoutId = setTimeout(initHighlight, 0) - return () => clearTimeout(timeoutId) + + const timeoutId = window.setTimeout(initHighlight, 0) + return () => { + cancelled = true + window.clearTimeout(timeoutId) + } }, [code, language]) return ( diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 98f5155b..b9568dde 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -93,6 +93,12 @@ if (import.meta.env.DEV && import.meta.hot) { import.meta.hot.on('vite:beforeFullReload', bumpSuppressRelayEmptyFeedToast) } const SHOW_COUNT = 20 // Increased from 10 to show more events at once, reducing scroll load frequency +/** + * When building visible rows, scan this many merged-timeline events at most. Previously we only looked at the first + * {@link showCount} events then filtered — with “posts only”, kind filters, and mutes, most of those could be hidden + * so the feed showed 2–4 notes while 100+ were already loaded (felt like a crawl). + */ +const MAX_TIMELINE_EVENTS_SCAN_FOR_VISIBLE = 2500 /** Hard cap after merging parallel one-shot fetches (e.g. interests = one REQ per topic). */ const ONE_SHOT_MERGED_CAP =100 /** Max events kept after merging parallel full-search REQ results across relays. */ @@ -672,30 +678,33 @@ const NoteList = forwardRef( const filteredEvents = useMemo(() => { const idSet = new Set() + const out: Event[] = [] + const target = showCount + const maxScan = Math.min( + timelineEventsForFilter.length, + Math.min(MAX_TIMELINE_EVENTS_SCAN_FOR_VISIBLE, Math.max(target * 60, 400)) + ) - return timelineEventsForFilter.slice(0, showCount).filter((evt) => { + for (let i = 0; i < maxScan && out.length < target; i++) { + const evt = timelineEventsForFilter[i] if (applyKindPickerInUi) { - if (!showKinds.includes(evt.kind)) return false - // Kind 1: show only OPs if showKind1OPs, only replies if showKind1Replies + if (!showKinds.includes(evt.kind)) continue if (evt.kind === kinds.ShortTextNote) { const isReply = isReplyNoteEvent(evt) - if (isReply && !showKind1Replies) return false - if (!isReply && !showKind1OPs) return false + if (isReply && !showKind1Replies) continue + if (!isReply && !showKind1OPs) continue } - // Kind 1111 (comments): show only if showKind1111 - if (evt.kind === ExtendedKind.COMMENT && !showKind1111) return false - // Git Republic releases: same visibility as kind-1 OPs - if (evt.kind === ExtendedKind.GIT_RELEASE && !showKind1OPs) return false + if (evt.kind === ExtendedKind.COMMENT && !showKind1111) continue + if (evt.kind === ExtendedKind.GIT_RELEASE && !showKind1OPs) continue } - if (shouldHideEvent(evt)) return false + if (shouldHideEvent(evt)) continue const id = isReplaceableEvent(evt.kind) ? getReplaceableCoordinateFromEvent(evt) : evt.id - if (idSet.has(id)) { - return false - } + if (idSet.has(id)) continue idSet.add(id) - return true - }) + out.push(evt) + } + return out }, [ timelineEventsForFilter, showCount, diff --git a/src/constants.ts b/src/constants.ts index 949d592a..04e27afb 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -55,8 +55,9 @@ export const MAX_CONCURRENT_SUBS_PER_RELAY = 7 * How many timeline shards may open relay subscriptions at once. Each shard sends one REQ per relay * in its list; with 6 shards in parallel a popular relay can see 6+ SUBs from this app alone, and a * second feed wave (remount / strict mode) pushes past strict relay caps (e.g. nostr.sovbit.host ≤10). + * 3 is a modest bump for faster multi-shard home loads; lower to 2 if a relay complains about SUB count. */ -export const TIMELINE_SHARD_SUBSCRIBE_CONCURRENCY = 2 +export const TIMELINE_SHARD_SUBSCRIBE_CONCURRENCY = 3 /** Max relays to publish each event to (outboxes first, then targets' inboxes, then extras). */ export const MAX_PUBLISH_RELAYS = 20 diff --git a/src/lib/event-ingest-filter.ts b/src/lib/event-ingest-filter.ts index 4ead6cfe..d78ca3cf 100644 --- a/src/lib/event-ingest-filter.ts +++ b/src/lib/event-ingest-filter.ts @@ -1,5 +1,5 @@ import { ExtendedKind } from '@/constants' -import { getRelayUrlFromRelayReviewEvent, getStarsFromRelayReviewEvent } from '@/lib/event-metadata' +import { getRelayUrlFromRelayReviewEvent } from '@/lib/event-metadata' import type { Event as NEvent } from 'nostr-tools' import { kinds } from 'nostr-tools' @@ -23,14 +23,12 @@ export function isStringifiedJsonObjectContentNostrEvent( } /** - * Kind-31987 noise: missing `d` (relay URL) or a parseable `rating` tag (see {@link getStarsFromRelayReviewEvent}). - * Content may be JSON or prose; structure is validated on tags, not `content`. + * Kind-31987 noise: missing `d` (relay URL). Rating formats differ across clients; do not drop at ingest + * (feeds and cards already treat unknown ratings as zero stars). */ export function isIncompleteRelayReviewIngest(event: NEvent): boolean { if (event.kind !== ExtendedKind.RELAY_REVIEW) return false - if (!getRelayUrlFromRelayReviewEvent(event)) return true - if (!getStarsFromRelayReviewEvent(event)) return true - return false + return !getRelayUrlFromRelayReviewEvent(event) } /** Single gate for subscribe/cache/IDB read paths: drop kind-1 JSON-object spam and malformed relay reviews. */ diff --git a/src/pages/secondary/RelayReviewsPage/index.tsx b/src/pages/secondary/RelayReviewsPage/index.tsx index c793bf91..06722e69 100644 --- a/src/pages/secondary/RelayReviewsPage/index.tsx +++ b/src/pages/secondary/RelayReviewsPage/index.tsx @@ -26,6 +26,19 @@ const RelayReviewsPage = forwardRef(({ url, index, hideTitlebar = false }: { url }, [hideTitlebar, registerPrimaryPanelRefresh, bumpFeed]) const normalizedUrl = useMemo(() => (url ? normalizeUrl(url) : undefined), [url]) + /** `d` tag values vary by client (raw vs normalized URL); REQ should OR-match like {@link RelayReviewsPreview}. */ + const relayReviewDTags = useMemo(() => { + const raw = url?.trim() + const norm = normalizedUrl?.trim() + const uniq: string[] = [] + const add = (s: string | undefined) => { + const t = s?.trim() + if (t && !uniq.includes(t)) uniq.push(t) + } + add(raw) + add(norm) + return uniq + }, [url, normalizedUrl]) /** Stable identity for session feed snapshot (decoupled from FAST_READ_RELAY_URLS JSON churn). */ const relayReviewsFeedSubscriptionKey = useMemo( () => @@ -33,14 +46,18 @@ const RelayReviewsPage = forwardRef(({ url, index, hideTitlebar = false }: { url [normalizedUrl] ) const reviewsSubRequests = useMemo(() => { - if (!normalizedUrl) return [] + if (!normalizedUrl || relayReviewDTags.length === 0) return [] return [ { urls: [normalizedUrl, ...FAST_READ_RELAY_URLS], - filter: { '#d': [normalizedUrl] } + filter: { + kinds: [ExtendedKind.RELAY_REVIEW], + '#d': relayReviewDTags, + limit: 100 + } } ] - }, [normalizedUrl]) + }, [normalizedUrl, relayReviewDTags]) const title = useMemo( () => (url ? t('Reviews for {{relay}}', { relay: simplifyUrl(url) }) : undefined), [url, t] @@ -63,6 +80,7 @@ const RelayReviewsPage = forwardRef(({ url, index, hideTitlebar = false }: { url showKinds={[ExtendedKind.RELAY_REVIEW]} subRequests={reviewsSubRequests} feedSubscriptionKey={relayReviewsFeedSubscriptionKey} + useFilterAsIs /> ) diff --git a/src/providers/MuteListProvider.tsx b/src/providers/MuteListProvider.tsx index dd9a3cfa..9848d7f7 100644 --- a/src/providers/MuteListProvider.tsx +++ b/src/providers/MuteListProvider.tsx @@ -69,20 +69,16 @@ export function MuteListProvider({ children }: { children: ReactNode }) { const storedDecryptedTags = await indexedDb.getMuteDecryptedTags(muteListEvent.id) if (storedDecryptedTags) { - return storedDecryptedTags + const cached = z.array(z.array(z.string())).safeParse(storedDecryptedTags) + if (cached.success) return cached.data + try { + await indexedDb.deleteMuteDecryptedTags(muteListEvent.id) + } catch { + /* ignore */ + } } - let plainText: string - try { - plainText = await nip04Decrypt(muteListEvent.pubkey, muteListEvent.content) - } catch (error) { - logMuteListPrivateIssueOnce( - muteListEvent.id, - 'Mute list private section could not be decrypted (public mutes still apply). Use a signing-capable login for private mutes.', - { cause: error instanceof Error ? error.message : String(error) } - ) - return [] - } + const plainText = await nip04Decrypt(muteListEvent.pubkey, muteListEvent.content) if (!plainText.trim()) { logMuteListPrivateIssueOnce( @@ -98,6 +94,11 @@ export function MuteListProvider({ children }: { children: ReactNode }) { await indexedDb.putMuteDecryptedTags(muteListEvent.id, privateTags) return privateTags } catch (error) { + try { + await indexedDb.deleteMuteDecryptedTags(muteListEvent.id) + } catch { + /* ignore */ + } logMuteListPrivateIssueOnce( muteListEvent.id, 'Mute list decrypted but private payload was not valid JSON (public mutes still apply).', diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 054d5d77..03af7990 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -1289,7 +1289,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { } const nip04Decrypt = async (pubkey: string, cipherText: string) => { - return signer?.nip04Decrypt(pubkey, cipherText) ?? '' + if (!signer) return '' + try { + return (await signer.nip04Decrypt(pubkey, cipherText)) ?? '' + } catch { + // Extensions often throw (padding / wrong key) while nsec path returns ''; keep call sites simple. + return '' + } } const checkLogin = async (cb?: () => T): Promise => { diff --git a/src/routes.tsx b/src/routes.tsx index 39dfaf63..f429a42c 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -67,8 +67,8 @@ const ROUTES = [ { path: '/users/:id', element: SR(ProfilePageLazy) }, { path: '/users/:id/following', element: SR(FollowingListPageLazy) }, { path: '/users/:id/relays', element: SR(OthersRelaySettingsPageLazy) }, - { path: '/relays/:url', element: SR(RelayPageLazy) }, { path: '/relays/:url/reviews', element: SR(RelayReviewsPageLazy) }, + { path: '/relays/:url', element: SR(RelayPageLazy) }, { path: '/home/relays/:url', element: SR(RelayPageLazy) }, { path: '/explore/relays/:url', element: SR(RelayPageLazy) }, { path: '/search', element: SR(SearchPageLazy) }, diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index 1a69a47c..df59724c 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -656,6 +656,26 @@ class IndexedDbService { }) } + async deleteMuteDecryptedTags(id: string): Promise { + await this.initPromise + return new Promise((resolve, reject) => { + if (!this.db) { + return resolve() + } + const transaction = this.db.transaction(StoreNames.MUTE_DECRYPTED_TAGS, 'readwrite') + const store = transaction.objectStore(StoreNames.MUTE_DECRYPTED_TAGS) + const req = store.delete(id) + req.onsuccess = () => { + transaction.commit() + resolve() + } + req.onerror = (event) => { + transaction.commit() + reject(event) + } + }) + } + async iterateProfileEvents(callback: (event: Event) => Promise): Promise { await this.initPromise if (!this.db) {