diff --git a/package-lock.json b/package-lock.json index df1e51a4..cbad62a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "jumble-imwald", - "version": "19.2.1", + "version": "19.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jumble-imwald", - "version": "19.2.1", + "version": "19.2.2", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index b222fe9c..7c464139 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jumble-imwald", - "version": "19.2.1", + "version": "19.2.2", "description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble", "private": true, "type": "module", diff --git a/src/components/LatestFromFollowsSection/index.tsx b/src/components/LatestFromFollowsSection/index.tsx index 51d696e9..c07d9471 100644 --- a/src/components/LatestFromFollowsSection/index.tsx +++ b/src/components/LatestFromFollowsSection/index.tsx @@ -1,17 +1,12 @@ import NoteCard from '@/components/NoteCard' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import { Skeleton } from '@/components/ui/skeleton' -import { - FAST_READ_RELAY_URLS, - FAST_WRITE_RELAY_URLS, - ExtendedKind, - SEARCHABLE_RELAY_URLS -} from '@/constants' +import { ExtendedKind } from '@/constants' +import { buildFollowOutboxAggregateReadUrls } from '@/lib/follow-outbox-aggregate-relays' import { shouldFilterEvent } from '@/lib/event-filtering' import { toProfile } from '@/lib/link' import { getPubkeysFromPTags } from '@/lib/tag' import { cn } from '@/lib/utils' -import { normalizeUrl } from '@/lib/url' import { useSecondaryPage } from '@/PageManager' import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' @@ -19,6 +14,7 @@ import { useMuteList } from '@/providers/MuteListProvider' import { useNostr } from '@/providers/NostrProvider' import { useUserTrust } from '@/providers/UserTrustProvider' import { queryService, replaceableEventService } from '@/services/client.service' +import type { TRelayList } from '@/types' import logger from '@/lib/logger' import { ChevronDown, ChevronRight, Star } from 'lucide-react' import { Event, kinds, nip19, NostrEvent } from 'nostr-tools' @@ -33,10 +29,12 @@ export const RECOMMENDED_FOLLOW_CURATOR_NPUB = 'npub1m4ny6hjqzepn4rxknuq94c2gpqzr29ufkkw7ttcxyak7v43n6vvsajc2jl' as const const MAX_FOLLOWS = 1000 -const AUTHORS_PER_BATCH = 12 +const AUTHORS_PER_BATCH = 20 const MAX_POSTS_PER_AUTHOR = 5 /** Enough headroom to often fill 5 notes per author in a batch. */ const BATCH_EVENT_LIMIT = 200 +/** Chunk size for batched NIP-65 list load while building the aggregate REQ set. */ +const RELAY_LIST_PRELOAD_CHUNK = 100 const FEED_KINDS = [ kinds.ShortTextNote, @@ -90,8 +88,8 @@ function recommendedCuratorHexPubkey(): string | null { export default function LatestFromFollowsSection() { const { t } = useTranslation() const { push } = useSecondaryPage() - const { pubkey, followListEvent, isInitialized, relayList } = useNostr() - const { favoriteRelays, blockedRelays } = useFavoriteRelays() + const { pubkey, followListEvent, isInitialized } = useNostr() + const { blockedRelays } = useFavoriteRelays() const { mutePubkeySet } = useMuteList() const { isEventDeleted } = useDeletedEvent() const { hideUntrustedNotes, isUserTrusted } = useUserTrust() @@ -114,18 +112,8 @@ export default function LatestFromFollowsSection() { const followsLabel: 'self' | 'recommended' = pubkey ? 'self' : 'recommended' const loadingFollowList = !pubkey && isInitialized && !guestListReady - const searchRelays = useMemo(() => { - const relays: string[] = [] - if (relayList) { - relays.push(...(relayList.read || []), ...(relayList.write || [])) - } - relays.push(...(favoriteRelays || [])) - relays.push(...FAST_READ_RELAY_URLS, ...FAST_WRITE_RELAY_URLS, ...SEARCHABLE_RELAY_URLS) - const normalized = Array.from( - new Set(relays.map((url) => normalizeUrl(url) || url).filter((url): url is string => !!url)) - ) - return normalized.filter((relay) => !blockedRelays.some((blocked) => relay.includes(blocked))) - }, [relayList, favoriteRelays, blockedRelays]) + const [aggregateRelayUrls, setAggregateRelayUrls] = useState([]) + const [aggregateRelaysReady, setAggregateRelaysReady] = useState(false) const acceptEvent = useCallback( (e: Event) => { @@ -179,10 +167,56 @@ export default function LatestFromFollowsSection() { } }, [isInitialized, pubkey]) - // Batch-fetch posts per slice of authors; update UI after each batch. + // Load each follow's NIP-65 list (IndexedDB + network), then aggregate first outboxes + READ_ONLY relays. + useEffect(() => { + if (!isInitialized || loadingFollowList) { + return + } + if (followPubkeys.length === 0) { + setAggregateRelayUrls([]) + setAggregateRelaysReady(true) + return + } + + let cancelled = false + setAggregateRelaysReady(false) + setAggregateRelayUrls([]) + + ;(async () => { + try { + // Dynamic import avoids a static cycle: client.service → replaceable-events → client.service + // (would break React context / HMR when this module loads early). + const { default: nostrClient } = await import('@/services/client.service') + const allLists: TRelayList[] = [] + for (let i = 0; i < followPubkeys.length; i += RELAY_LIST_PRELOAD_CHUNK) { + if (cancelled) return + const chunk = followPubkeys.slice(i, i + RELAY_LIST_PRELOAD_CHUNK) + const lists = await nostrClient.fetchRelayLists(chunk) + allLists.push(...lists) + } + if (cancelled) return + const urls = buildFollowOutboxAggregateReadUrls(allLists, blockedRelays) + setAggregateRelayUrls(urls) + } catch (err) { + logger.warn('[LatestFromFollows] Failed to build follow outbox aggregate relays', err) + if (!cancelled) { + setAggregateRelayUrls(buildFollowOutboxAggregateReadUrls([], blockedRelays)) + } + } finally { + if (!cancelled) setAggregateRelaysReady(true) + } + })() + + return () => { + cancelled = true + } + }, [followPubkeys, blockedRelays, isInitialized, loadingFollowList]) + + // Batch-fetch posts per slice of authors against the aggregate relay set. useEffect(() => { if (!isInitialized || loadingFollowList) return if (followPubkeys.length === 0) return + if (!aggregateRelaysReady) return abortedRef.current = false let cancelled = false @@ -196,7 +230,7 @@ export default function LatestFromFollowsSection() { const batch = followPubkeys.slice(i, i + AUTHORS_PER_BATCH) try { const raw = await queryService.fetchEvents( - searchRelays, + aggregateRelayUrls, { kinds: [...FEED_KINDS], authors: batch, @@ -220,7 +254,14 @@ export default function LatestFromFollowsSection() { abortedRef.current = true setBatchBusy(false) } - }, [followPubkeys, searchRelays, loadingFollowList, isInitialized, acceptEvent]) + }, [ + followPubkeys, + aggregateRelayUrls, + aggregateRelaysReady, + loadingFollowList, + isInitialized, + acceptEvent + ]) const sortedRowPubkeys = useMemo(() => { const withPosts = followPubkeys.filter((pk) => (postsByPubkey.get(pk)?.length ?? 0) > 0) diff --git a/src/components/SearchResult/index.tsx b/src/components/SearchResult/index.tsx index e29161e7..397b483d 100644 --- a/src/components/SearchResult/index.tsx +++ b/src/components/SearchResult/index.tsx @@ -4,7 +4,6 @@ import NormalFeed from '../NormalFeed' import Profile from '../Profile' import { ProfileListBySearch } from '../ProfileListBySearch' import Relay from '../Relay' -import TrendingNotes from '../TrendingNotes' import { useNostr } from '@/providers/NostrProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { normalizeUrl } from '@/lib/url' @@ -41,7 +40,7 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa }, [pubkey, relayList, favoriteRelays, blockedRelays]) if (!searchParams) { - return + return null } if (searchParams.type === 'profile') { return diff --git a/src/components/TrendingNotes/index.tsx b/src/components/TrendingNotes/index.tsx deleted file mode 100644 index e4535f4e..00000000 --- a/src/components/TrendingNotes/index.tsx +++ /dev/null @@ -1,430 +0,0 @@ -import NoteCard, { NoteCardLoadingSkeleton } from '@/components/NoteCard' -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' -import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' -import { cn } from '@/lib/utils' -import { useDeletedEvent } from '@/providers/DeletedEventProvider' -import { useUserTrust } from '@/providers/UserTrustProvider' -import { queryService } from '@/services/client.service' -import { NostrEvent } from 'nostr-tools' -import { useEffect, useMemo, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { useNostr } from '@/providers/NostrProvider' -import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' -import { useZap } from '@/providers/ZapProvider' -import noteStatsService from '@/services/note-stats.service' -import { FAST_READ_RELAY_URLS } from '@/constants' -import logger from '@/lib/logger' -import { normalizeUrl } from '@/lib/url' -import { Skeleton } from '@/components/ui/skeleton' -import { ChevronDown } from 'lucide-react' - -const SHOW_COUNT = 25 -const CACHE_DURATION = 30 * 60 * 1000 // 30 minutes - -let cachedCustomEvents: { - events: Array<{ event: NostrEvent; score: number }> - timestamp: number -} | null = null - -let isInitializing = false - -type SortOrder = 'newest' | 'oldest' | 'most-popular' | 'least-popular' - -export type TrendingNotesVariant = 'page' | 'searchAccordion' - -export default function TrendingNotes({ variant = 'page' }: { variant?: TrendingNotesVariant }) { - const { t } = useTranslation() - const { isEventDeleted } = useDeletedEvent() - const { hideUntrustedNotes, isUserTrusted } = useUserTrust() - const { pubkey, relayList } = useNostr() - const { favoriteRelays } = useFavoriteRelays() - const { zapReplyThreshold } = useZap() - const [showCount, setShowCount] = useState(SHOW_COUNT) - const [sortOrder, setSortOrder] = useState('most-popular') - const [cacheEvents, setCacheEvents] = useState([]) - const [cacheLoading, setCacheLoading] = useState(false) - const [accordionOpen, setAccordionOpen] = useState(false) - const bottomRef = useRef(null) - - const trendingRelaySource = useMemo<'favorites' | 'default'>(() => { - if (!pubkey) return 'default' - const hasFavorites = favoriteRelays.length > 0 - const hasRead = (relayList?.read?.length ?? 0) > 0 - if (hasFavorites || hasRead) return 'favorites' - return 'default' - }, [pubkey, favoriteRelays, relayList]) - - const getRelays = useMemo(() => { - const relays: string[] = [] - - if (pubkey) { - relays.push(...favoriteRelays) - if (relayList?.read) { - relays.push(...relayList.read) - } - if (relays.length === 0) { - relays.push(...FAST_READ_RELAY_URLS) - } - } else { - relays.push(...FAST_READ_RELAY_URLS) - } - - const normalized = relays.map((url) => normalizeUrl(url)).filter((url): url is string => !!url) - - return Array.from(new Set(normalized)) - }, [pubkey, favoriteRelays, relayList]) - - useEffect(() => { - const initializeCache = async () => { - if (isInitializing) return - if (cacheEvents.length > 0) { - logger.debug('[TrendingNotes] Cache already populated, skipping initialization') - return - } - - const now = Date.now() - - if (cachedCustomEvents && now - cachedCustomEvents.timestamp < CACHE_DURATION) { - const allEvents = cachedCustomEvents.events.map((item) => item.event) - logger.debug('[TrendingNotes] Using existing cache - loading', allEvents.length, 'events') - setCacheEvents(allEvents) - setCacheLoading(false) - return - } - - isInitializing = true - setCacheLoading(true) - const relays = getRelays - - const timeoutId = setTimeout(() => { - logger.debug('[TrendingNotes] Cache initialization timeout - forcing completion') - isInitializing = false - setCacheLoading(false) - }, 180000) - - if (relays.length === 0) { - clearTimeout(timeoutId) - isInitializing = false - setCacheLoading(false) - return - } - - try { - const allEvents: NostrEvent[] = [] - const twentyFourHoursAgo = Math.floor(Date.now() / 1000) - 24 * 60 * 60 - const batchSize = 3 - const recentEvents: NostrEvent[] = [] - - for (let i = 0; i < relays.length; i += batchSize) { - const batch = relays.slice(i, i + batchSize) - const batchPromises = batch.map(async (relay) => { - try { - const events = await queryService.fetchEvents([relay], { - kinds: [1, 11, 30023, 9802, 20, 21, 22], - since: twentyFourHoursAgo, - limit: 200 - }) - return events - } catch (error) { - logger.warn(`[TrendingNotes] Error fetching from relay ${relay}:`, error) - return [] - } - }) - - const batchResults = await Promise.all(batchPromises) - recentEvents.push(...batchResults.flat()) - - if (i + batchSize < relays.length) { - await new Promise((resolve) => setTimeout(resolve, 200)) - } - } - - allEvents.push(...recentEvents) - - const topLevelEvents = allEvents.filter((event) => { - const eTags = event.tags.filter((tag) => tag[0] === 'e') - return eTags.length === 0 - }) - - const filteredEvents = topLevelEvents.filter((event) => { - const hasNsfwTag = event.tags.some( - (tag) => tag[0] === 't' && tag[1] && tag[1].toLowerCase() === 'nsfw' - ) - const hasSensitiveTag = event.tags.some( - (tag) => tag[0] === 't' && tag[1] && tag[1].toLowerCase() === 'sensitive' - ) - const hasNsfwHashtag = event.content.toLowerCase().includes('#nsfw') - const hasContentWarning = event.tags.some((tag) => tag[0] === 'content-warning') - const hasContentWarningL = event.tags.some( - (tag) => tag[0] === 'L' && tag[1] && tag[1].toLowerCase() === 'content-warning' - ) - const hasContentWarningl = event.tags.some( - (tag) => tag[0] === 'l' && tag[1] && tag[1].toLowerCase() === 'content-warning' - ) - return ( - !hasNsfwTag && - !hasSensitiveTag && - !hasNsfwHashtag && - !hasContentWarning && - !hasContentWarningL && - !hasContentWarningl - ) - }) - - const eventsNeedingStats = filteredEvents.filter((event) => !noteStatsService.getNoteStats(event.id)) - - if (eventsNeedingStats.length > 0) { - const statsBatchSize = 10 - for (let i = 0; i < eventsNeedingStats.length; i += statsBatchSize) { - const batch = eventsNeedingStats.slice(i, i + statsBatchSize) - await Promise.all( - batch.map((event) => noteStatsService.fetchNoteStats(event, undefined, favoriteRelays).catch(() => {})) - ) - if (i + statsBatchSize < eventsNeedingStats.length) { - await new Promise((resolve) => setTimeout(resolve, 200)) - } - } - } - - const scoredEvents = filteredEvents.map((event) => { - const stats = noteStatsService.getNoteStats(event.id) - let score = 0 - if (stats?.likes) score += stats.likes.length - if (stats?.zaps) { - stats.zaps.forEach((zap) => { - score += zap.amount >= zapReplyThreshold ? 8 : 1 - }) - } - if (stats?.replies) score += stats.replies.length * 3 - if (stats?.reposts) score += stats.reposts.length * 5 - if (stats?.quotes) score += stats.quotes.length * 8 - if (stats?.highlights) score += stats.highlights.length * 10 - return { event, score } - }) - - cachedCustomEvents = { - events: scoredEvents, - timestamp: now - } - - setCacheEvents(filteredEvents) - } catch (error) { - logger.error('[TrendingNotes] Error initializing cache:', error) - } finally { - clearTimeout(timeoutId) - isInitializing = false - setCacheLoading(false) - } - } - - initializeCache() - }, []) - - const relaysFilteredEventsAll = useMemo(() => { - const idSet = new Set() - - const filtered = cacheEvents.filter((evt) => { - if (isEventDeleted(evt)) return false - if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) return false - const id = isReplaceableEvent(evt.kind) ? getReplaceableCoordinateFromEvent(evt) : evt.id - if (idSet.has(id)) return false - idSet.add(id) - return true - }) - - filtered.sort((a, b) => { - if (sortOrder === 'newest') return b.created_at - a.created_at - if (sortOrder === 'oldest') return a.created_at - b.created_at - if (sortOrder === 'most-popular' || sortOrder === 'least-popular') { - const statsA = noteStatsService.getNoteStats(a.id) - const statsB = noteStatsService.getNoteStats(b.id) - let scoreA = 0 - let scoreB = 0 - if (statsA) { - scoreA += statsA.likes?.length || 0 - scoreA += (statsA.replies?.length || 0) * 3 - scoreA += (statsA.reposts?.length || 0) * 5 - scoreA += (statsA.quotes?.length || 0) * 8 - scoreA += (statsA.highlights?.length || 0) * 10 - if (statsA.zaps) { - statsA.zaps.forEach((zap) => { - scoreA += zap.amount >= zapReplyThreshold ? 8 : 1 - }) - } - } - if (statsB) { - scoreB += statsB.likes?.length || 0 - scoreB += (statsB.replies?.length || 0) * 3 - scoreB += (statsB.reposts?.length || 0) * 5 - scoreB += (statsB.quotes?.length || 0) * 8 - scoreB += (statsB.highlights?.length || 0) * 10 - if (statsB.zaps) { - statsB.zaps.forEach((zap) => { - scoreB += zap.amount >= zapReplyThreshold ? 8 : 1 - }) - } - } - return sortOrder === 'most-popular' ? scoreB - scoreA : scoreA - scoreB - } - return 0 - }) - - return filtered - }, [cacheEvents, hideUntrustedNotes, isEventDeleted, isUserTrusted, sortOrder, zapReplyThreshold]) - - const relaysFilteredEvents = useMemo( - () => relaysFilteredEventsAll.slice(0, showCount), - [relaysFilteredEventsAll, showCount] - ) - - useEffect(() => { - const totalLength = relaysFilteredEventsAll.length - if (showCount >= totalLength) return - - const options = { root: null, rootMargin: '10px', threshold: 0.1 } - const observerInstance = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting) { - setShowCount((prev) => prev + SHOW_COUNT) - } - }, options) - - const currentBottomRef = bottomRef.current - if (currentBottomRef) observerInstance.observe(currentBottomRef) - - return () => { - if (currentBottomRef) observerInstance.unobserve(currentBottomRef) - } - }, [relaysFilteredEventsAll.length, showCount, cacheLoading]) - - const headerTitle = - trendingRelaySource === 'favorites' - ? t('Trending on Your Favorite Relays') - : t('Trending on the Default Relays') - - const sortToolbar = ( -
- {t('Sort')}: -
- - - - -
-
- ) - - const notesBody = ( - <> - {cacheLoading && cacheEvents.length === 0 ? ( -
- {t('Loading trending notes from your relays...')} -
- ) : null} - - {relaysFilteredEvents.map((event) => ( - - ))} - - {cacheLoading || showCount < relaysFilteredEventsAll.length ? ( -
- -
- ) : ( -
{t('no more notes')}
- )} - - ) - - if (variant === 'searchAccordion') { - return ( - - - - {headerTitle} - {cacheLoading && cacheEvents.length === 0 ? ( - - ) : null} - - - - -
-
{sortToolbar}
- {notesBody} -
-
-
- ) - } - - return ( -
-
-
-

{headerTitle}

-
- {sortToolbar} -
- {notesBody} -
- ) -} diff --git a/src/hooks/useFetchProfile.tsx b/src/hooks/useFetchProfile.tsx index 7bde505f..0632fea4 100644 --- a/src/hooks/useFetchProfile.tsx +++ b/src/hooks/useFetchProfile.tsx @@ -504,7 +504,7 @@ export function useFetchProfile(id?: string, skipCache = false) { if (skipCache) { // If no profile was found, periodically re-check (profiles might load asynchronously) // REDUCED: Check every 10 seconds for up to 30 seconds (3 checks) to prevent too many intervals - // This reduces memory usage when many profiles are being fetched (e.g., trending page) + // This reduces memory usage when many profiles are being fetched (e.g., large search results) let checkCount = 0 const maxChecks = 3 // Reduced from 4 to further reduce load const startTime = Date.now() diff --git a/src/lib/follow-outbox-aggregate-relays.ts b/src/lib/follow-outbox-aggregate-relays.ts new file mode 100644 index 00000000..cdd5a6ee --- /dev/null +++ b/src/lib/follow-outbox-aggregate-relays.ts @@ -0,0 +1,44 @@ +import { READ_ONLY_RELAY_URLS } from '@/constants' +import { normalizeUrl } from '@/lib/url' +import { relayUrlsLocalsFirst } from '@/lib/relay-url-priority' +import type { TRelayList } from '@/types' + +/** First N NIP-65 `write` (outbox) URLs per followed pubkey, follow-list order; locals first per author. */ +export const FOLLOW_OUTBOX_AGGREGATE_PER_AUTHOR = 2 + +/** Plain `ws://` relays are almost always someone else's LAN; the client cannot use them for third-party reads. */ +function isNonPublicWsRelayUrl(normalizedUrl: string): boolean { + return normalizedUrl.toLowerCase().startsWith('ws://') +} + +/** + * Merge each author's outboxes (capped per author) with {@link READ_ONLY_RELAY_URLS}: + * normalized, blocked-stripped, deduped (first occurrence wins). + */ +export function buildFollowOutboxAggregateReadUrls( + relayLists: readonly TRelayList[], + blockedRelays: readonly string[] +): string[] { + const blocked = new Set(blockedRelays.map((b) => normalizeUrl(b) || b).filter(Boolean)) + const seen = new Set() + const out: string[] = [] + + for (const rl of relayLists) { + const writes = relayUrlsLocalsFirst(rl.write ?? []) + for (const u of writes.slice(0, FOLLOW_OUTBOX_AGGREGATE_PER_AUTHOR)) { + const n = normalizeUrl(u) || u + if (!n || isNonPublicWsRelayUrl(n) || blocked.has(n) || seen.has(n)) continue + seen.add(n) + out.push(n) + } + } + + for (const u of READ_ONLY_RELAY_URLS) { + const n = normalizeUrl(u) || u + if (!n || isNonPublicWsRelayUrl(n) || blocked.has(n) || seen.has(n)) continue + seen.add(n) + out.push(n) + } + + return out +} diff --git a/src/pages/primary/SearchPage/index.tsx b/src/pages/primary/SearchPage/index.tsx index e4c4dc8c..79f7c926 100644 --- a/src/pages/primary/SearchPage/index.tsx +++ b/src/pages/primary/SearchPage/index.tsx @@ -91,7 +91,6 @@ const SearchPage = forwardRef((_, ref) => { ) : (
-
)} diff --git a/src/services/client.service.ts b/src/services/client.service.ts index b2730ad7..6ddc5179 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -123,7 +123,7 @@ class ClientService extends EventTarget { private sessionRelayPublishStats = new Map() /** - * IndexedDB profile index + NIP-66 relay discovery run once per page session; followings prewarm runs when logged in. + * IndexedDB profile index + NIP-66 relay discovery run once per page session; followings prewarm (metadata + kind 10002) runs when logged in. * @see {@link runSessionPrewarm} */ private sessionPrewarmBaseCompleted = false @@ -1969,23 +1969,29 @@ class ClientService extends EventTarget { }) return } - logger.info('[client] Prewarm: following profile fetch started', { + logger.info('[client] Prewarm: following profile + NIP-65 relay list fetch started', { pubkeySlice: pubkey.slice(0, 12), followingCount: followings.length }) - for (let i = 0; i * 20 < followings.length; i++) { + let relayListResolved = 0 + const chunkSize = 20 + for (let i = 0; i * chunkSize < followings.length; i++) { if (signal.aborted) { - logger.info('[client] Prewarm: following profiles aborted', { pubkeySlice: pubkey.slice(0, 12) }) + logger.info('[client] Prewarm: following profiles + relay lists aborted', { pubkeySlice: pubkey.slice(0, 12) }) return } - await Promise.all( - followings.slice(i * 20, (i + 1) * 20).map((pk) => this.fetchProfileEvent(pk)) - ) + const chunk = followings.slice(i * chunkSize, (i + 1) * chunkSize) + const [relayListEvents] = await Promise.all([ + this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays(chunk, kinds.RelayList), + Promise.all(chunk.map((pk) => this.fetchProfileEvent(pk))) + ]) + relayListResolved += relayListEvents.filter(Boolean).length await new Promise((resolve) => setTimeout(resolve, 1000)) } - logger.info('[client] Prewarm: following profile fetch finished', { + logger.info('[client] Prewarm: following profile + NIP-65 relay list fetch finished', { pubkeySlice: pubkey.slice(0, 12), - followingCount: followings.length + followingCount: followings.length, + relayListEventsResolved: relayListResolved }) }