Browse Source

remove trending

fix search and follows
imwald
Silberengel 1 month ago
parent
commit
bb61b7e0d2
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 91
      src/components/LatestFromFollowsSection/index.tsx
  4. 3
      src/components/SearchResult/index.tsx
  5. 430
      src/components/TrendingNotes/index.tsx
  6. 2
      src/hooks/useFetchProfile.tsx
  7. 44
      src/lib/follow-outbox-aggregate-relays.ts
  8. 1
      src/pages/primary/SearchPage/index.tsx
  9. 24
      src/services/client.service.ts

4
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "jumble-imwald", "name": "jumble-imwald",
"version": "19.2.1", "version": "19.2.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "jumble-imwald", "name": "jumble-imwald",
"version": "19.2.1", "version": "19.2.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "jumble-imwald", "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", "description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble",
"private": true, "private": true,
"type": "module", "type": "module",

91
src/components/LatestFromFollowsSection/index.tsx

@ -1,17 +1,12 @@
import NoteCard from '@/components/NoteCard' import NoteCard from '@/components/NoteCard'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { import { ExtendedKind } from '@/constants'
FAST_READ_RELAY_URLS, import { buildFollowOutboxAggregateReadUrls } from '@/lib/follow-outbox-aggregate-relays'
FAST_WRITE_RELAY_URLS,
ExtendedKind,
SEARCHABLE_RELAY_URLS
} from '@/constants'
import { shouldFilterEvent } from '@/lib/event-filtering' import { shouldFilterEvent } from '@/lib/event-filtering'
import { toProfile } from '@/lib/link' import { toProfile } from '@/lib/link'
import { getPubkeysFromPTags } from '@/lib/tag' import { getPubkeysFromPTags } from '@/lib/tag'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { normalizeUrl } from '@/lib/url'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
@ -19,6 +14,7 @@ import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/providers/UserTrustProvider' import { useUserTrust } from '@/providers/UserTrustProvider'
import { queryService, replaceableEventService } from '@/services/client.service' import { queryService, replaceableEventService } from '@/services/client.service'
import type { TRelayList } from '@/types'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { ChevronDown, ChevronRight, Star } from 'lucide-react' import { ChevronDown, ChevronRight, Star } from 'lucide-react'
import { Event, kinds, nip19, NostrEvent } from 'nostr-tools' import { Event, kinds, nip19, NostrEvent } from 'nostr-tools'
@ -33,10 +29,12 @@ export const RECOMMENDED_FOLLOW_CURATOR_NPUB =
'npub1m4ny6hjqzepn4rxknuq94c2gpqzr29ufkkw7ttcxyak7v43n6vvsajc2jl' as const 'npub1m4ny6hjqzepn4rxknuq94c2gpqzr29ufkkw7ttcxyak7v43n6vvsajc2jl' as const
const MAX_FOLLOWS = 1000 const MAX_FOLLOWS = 1000
const AUTHORS_PER_BATCH = 12 const AUTHORS_PER_BATCH = 20
const MAX_POSTS_PER_AUTHOR = 5 const MAX_POSTS_PER_AUTHOR = 5
/** Enough headroom to often fill 5 notes per author in a batch. */ /** Enough headroom to often fill 5 notes per author in a batch. */
const BATCH_EVENT_LIMIT = 200 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 = [ const FEED_KINDS = [
kinds.ShortTextNote, kinds.ShortTextNote,
@ -90,8 +88,8 @@ function recommendedCuratorHexPubkey(): string | null {
export default function LatestFromFollowsSection() { export default function LatestFromFollowsSection() {
const { t } = useTranslation() const { t } = useTranslation()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { pubkey, followListEvent, isInitialized, relayList } = useNostr() const { pubkey, followListEvent, isInitialized } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { blockedRelays } = useFavoriteRelays()
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { isEventDeleted } = useDeletedEvent() const { isEventDeleted } = useDeletedEvent()
const { hideUntrustedNotes, isUserTrusted } = useUserTrust() const { hideUntrustedNotes, isUserTrusted } = useUserTrust()
@ -114,18 +112,8 @@ export default function LatestFromFollowsSection() {
const followsLabel: 'self' | 'recommended' = pubkey ? 'self' : 'recommended' const followsLabel: 'self' | 'recommended' = pubkey ? 'self' : 'recommended'
const loadingFollowList = !pubkey && isInitialized && !guestListReady const loadingFollowList = !pubkey && isInitialized && !guestListReady
const searchRelays = useMemo(() => { const [aggregateRelayUrls, setAggregateRelayUrls] = useState<string[]>([])
const relays: string[] = [] const [aggregateRelaysReady, setAggregateRelaysReady] = useState(false)
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 acceptEvent = useCallback( const acceptEvent = useCallback(
(e: Event) => { (e: Event) => {
@ -179,10 +167,56 @@ export default function LatestFromFollowsSection() {
} }
}, [isInitialized, pubkey]) }, [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(() => { useEffect(() => {
if (!isInitialized || loadingFollowList) return if (!isInitialized || loadingFollowList) return
if (followPubkeys.length === 0) return if (followPubkeys.length === 0) return
if (!aggregateRelaysReady) return
abortedRef.current = false abortedRef.current = false
let cancelled = false let cancelled = false
@ -196,7 +230,7 @@ export default function LatestFromFollowsSection() {
const batch = followPubkeys.slice(i, i + AUTHORS_PER_BATCH) const batch = followPubkeys.slice(i, i + AUTHORS_PER_BATCH)
try { try {
const raw = await queryService.fetchEvents( const raw = await queryService.fetchEvents(
searchRelays, aggregateRelayUrls,
{ {
kinds: [...FEED_KINDS], kinds: [...FEED_KINDS],
authors: batch, authors: batch,
@ -220,7 +254,14 @@ export default function LatestFromFollowsSection() {
abortedRef.current = true abortedRef.current = true
setBatchBusy(false) setBatchBusy(false)
} }
}, [followPubkeys, searchRelays, loadingFollowList, isInitialized, acceptEvent]) }, [
followPubkeys,
aggregateRelayUrls,
aggregateRelaysReady,
loadingFollowList,
isInitialized,
acceptEvent
])
const sortedRowPubkeys = useMemo(() => { const sortedRowPubkeys = useMemo(() => {
const withPosts = followPubkeys.filter((pk) => (postsByPubkey.get(pk)?.length ?? 0) > 0) const withPosts = followPubkeys.filter((pk) => (postsByPubkey.get(pk)?.length ?? 0) > 0)

3
src/components/SearchResult/index.tsx

@ -4,7 +4,6 @@ import NormalFeed from '../NormalFeed'
import Profile from '../Profile' import Profile from '../Profile'
import { ProfileListBySearch } from '../ProfileListBySearch' import { ProfileListBySearch } from '../ProfileListBySearch'
import Relay from '../Relay' import Relay from '../Relay'
import TrendingNotes from '../TrendingNotes'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
@ -41,7 +40,7 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa
}, [pubkey, relayList, favoriteRelays, blockedRelays]) }, [pubkey, relayList, favoriteRelays, blockedRelays])
if (!searchParams) { if (!searchParams) {
return <TrendingNotes variant="searchAccordion" /> return null
} }
if (searchParams.type === 'profile') { if (searchParams.type === 'profile') {
return <Profile id={searchParams.search} /> return <Profile id={searchParams.search} />

430
src/components/TrendingNotes/index.tsx

@ -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<SortOrder>('most-popular')
const [cacheEvents, setCacheEvents] = useState<NostrEvent[]>([])
const [cacheLoading, setCacheLoading] = useState(false)
const [accordionOpen, setAccordionOpen] = useState(false)
const bottomRef = useRef<HTMLDivElement>(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<string>()
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 = (
<div className="flex flex-wrap items-center gap-2 px-4 pb-3">
<span className="text-xs text-muted-foreground">{t('Sort')}:</span>
<div className="flex flex-wrap gap-1">
<button
type="button"
onClick={() => setSortOrder('newest')}
className={`rounded px-2 py-1 text-xs transition-colors ${
sortOrder === 'newest'
? 'bg-secondary text-secondary-foreground'
: 'bg-muted/50 text-muted-foreground hover:bg-muted'
}`}
>
{t('newest')}
</button>
<button
type="button"
onClick={() => setSortOrder('oldest')}
className={`rounded px-2 py-1 text-xs transition-colors ${
sortOrder === 'oldest'
? 'bg-secondary text-secondary-foreground'
: 'bg-muted/50 text-muted-foreground hover:bg-muted'
}`}
>
{t('oldest')}
</button>
<button
type="button"
onClick={() => setSortOrder('most-popular')}
className={`rounded px-2 py-1 text-xs transition-colors ${
sortOrder === 'most-popular'
? 'bg-secondary text-secondary-foreground'
: 'bg-muted/50 text-muted-foreground hover:bg-muted'
}`}
>
{t('most popular')}
</button>
<button
type="button"
onClick={() => setSortOrder('least-popular')}
className={`rounded px-2 py-1 text-xs transition-colors ${
sortOrder === 'least-popular'
? 'bg-secondary text-secondary-foreground'
: 'bg-muted/50 text-muted-foreground hover:bg-muted'
}`}
>
{t('least popular')}
</button>
</div>
</div>
)
const notesBody = (
<>
{cacheLoading && cacheEvents.length === 0 ? (
<div
className={
variant === 'searchAccordion'
? 'px-4 py-6 text-center text-sm text-muted-foreground'
: 'mt-8 text-center text-sm text-muted-foreground'
}
>
{t('Loading trending notes from your relays...')}
</div>
) : null}
{relaysFilteredEvents.map((event) => (
<NoteCard
key={
isReplaceableEvent((event as NostrEvent).kind)
? getReplaceableCoordinateFromEvent(event as NostrEvent)
: (event as NostrEvent).id
}
className="w-full"
event={event}
/>
))}
{cacheLoading || showCount < relaysFilteredEventsAll.length ? (
<div ref={bottomRef}>
<NoteCardLoadingSkeleton />
</div>
) : (
<div className="mt-2 text-center text-sm text-muted-foreground">{t('no more notes')}</div>
)}
</>
)
if (variant === 'searchAccordion') {
return (
<Collapsible open={accordionOpen} onOpenChange={setAccordionOpen} className="min-w-0">
<CollapsibleTrigger className="flex w-full items-center justify-between gap-2 rounded-lg border border-border/80 bg-muted/15 px-3 py-2.5 text-left hover:bg-muted/25">
<span className="flex min-w-0 flex-1 items-center gap-2">
<span className="text-base font-semibold leading-tight">{headerTitle}</span>
{cacheLoading && cacheEvents.length === 0 ? (
<Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden />
) : null}
</span>
<ChevronDown
className={cn(
'size-5 shrink-0 text-muted-foreground transition-transform',
accordionOpen && 'rotate-180'
)}
/>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden">
<div className="mt-2 rounded-lg border border-border/60 bg-background">
<div className="border-b border-border/60 bg-muted/10">{sortToolbar}</div>
{notesBody}
</div>
</CollapsibleContent>
</Collapsible>
)
}
return (
<div className="min-h-screen">
<div className="sticky top-12 z-30 border-b bg-background">
<div className="px-4 pb-3 pt-3">
<h2 className="text-lg font-bold leading-tight">{headerTitle}</h2>
</div>
{sortToolbar}
</div>
{notesBody}
</div>
)
}

2
src/hooks/useFetchProfile.tsx

@ -504,7 +504,7 @@ export function useFetchProfile(id?: string, skipCache = false) {
if (skipCache) { if (skipCache) {
// If no profile was found, periodically re-check (profiles might load asynchronously) // 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 // 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 let checkCount = 0
const maxChecks = 3 // Reduced from 4 to further reduce load const maxChecks = 3 // Reduced from 4 to further reduce load
const startTime = Date.now() const startTime = Date.now()

44
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<string>()
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
}

1
src/pages/primary/SearchPage/index.tsx

@ -91,7 +91,6 @@ const SearchPage = forwardRef<TPageRef>((_, ref) => {
) : ( ) : (
<div className="mb-4 min-w-0 space-y-2"> <div className="mb-4 min-w-0 space-y-2">
<LatestFromFollowsSection /> <LatestFromFollowsSection />
<SearchResult searchParams={null} />
</div> </div>
)} )}
</div> </div>

24
src/services/client.service.ts

@ -123,7 +123,7 @@ class ClientService extends EventTarget {
private sessionRelayPublishStats = new Map<string, { successCount: number; sumLatencyMs: number }>() private sessionRelayPublishStats = new Map<string, { successCount: number; sumLatencyMs: number }>()
/** /**
* 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} * @see {@link runSessionPrewarm}
*/ */
private sessionPrewarmBaseCompleted = false private sessionPrewarmBaseCompleted = false
@ -1969,23 +1969,29 @@ class ClientService extends EventTarget {
}) })
return 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), pubkeySlice: pubkey.slice(0, 12),
followingCount: followings.length 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) { 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 return
} }
await Promise.all( const chunk = followings.slice(i * chunkSize, (i + 1) * chunkSize)
followings.slice(i * 20, (i + 1) * 20).map((pk) => this.fetchProfileEvent(pk)) 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)) 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), pubkeySlice: pubkey.slice(0, 12),
followingCount: followings.length followingCount: followings.length,
relayListEventsResolved: relayListResolved
}) })
} }

Loading…
Cancel
Save