import NoteCard from '@/components/NoteCard' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import { Skeleton } from '@/components/ui/skeleton' import { ExtendedKind, NIP71_VIDEO_KINDS } from '@/constants' import { buildFollowOutboxAggregateReadUrls } from '@/lib/follow-outbox-aggregate-relays' import { buildSearchFollowsFeedScopeKey, fingerprintRelaySet, fingerprintSortedPubkeys, postsMapToRecord, postsRecordToMap, readSearchFollowsFeedCache, writeSearchFollowsFeedCache } from '@/lib/search-follows-feed-cache' import { shouldFilterEvent } from '@/lib/event-filtering' import { toProfile } from '@/lib/link' import { getPubkeysFromPTags } from '@/lib/tag' import { cn } from '@/lib/utils' import { useSecondaryPage } from '@/PageManager' import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useMuteList } from '@/contexts/mute-list-context' import { muteSetHas } from '@/lib/mute-set' import { useNostr } from '@/providers/NostrProvider' import { useUserTrust } from '@/contexts/user-trust-context' import { queryService, replaceableEventService } from '@/services/client.service' import type { TRelayList } from '@/types' import logger from '@/lib/logger' import { ChevronRight, Star } from 'lucide-react' import { Event, kinds, nip19, NostrEvent } from 'nostr-tools' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { FormattedTimestamp } from '../FormattedTimestamp' import UserAvatar from '../UserAvatar' import Username from '../Username' /** Curated follow list for guests (hex from npub). */ const RECOMMENDED_FOLLOW_CURATOR_NPUB = 'npub1m4ny6hjqzepn4rxknuq94c2gpqzr29ufkkw7ttcxyak7v43n6vvsajc2jl' as const const MAX_FOLLOWS = 1000 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, ExtendedKind.DISCUSSION, kinds.LongFormArticle, kinds.Highlights, ExtendedKind.PICTURE, ...NIP71_VIDEO_KINDS, ExtendedKind.COMMENT, kinds.Repost, ExtendedKind.GENERIC_REPOST ] as number[] const feedKindSet = new Set(FEED_KINDS) const LOG = '[LatestFromFollows]' function mergeBatchPosts( prev: Map, incoming: NostrEvent[], batchAuthors: string[] ): Map { const next = new Map(prev) /** Follow list pubkeys are lowercased in `getPubkeysFromPTags`; relay `pubkey` may be mixed-case hex. */ const authorSet = new Set(batchAuthors.map((a) => a.toLowerCase())) const filtered = incoming.filter((e) => authorSet.has(e.pubkey.toLowerCase())) for (const pk of batchAuthors) { const pkNorm = pk.toLowerCase() const prevList = next.get(pk) ?? [] const newForPk = filtered.filter((e) => e.pubkey.toLowerCase() === pkNorm) const byId = new Map() for (const e of prevList) byId.set(e.id, e) for (const e of newForPk) { const ex = byId.get(e.id) if (!ex || e.created_at >= ex.created_at) byId.set(e.id, e) } const sorted = [...byId.values()] .sort((a, b) => b.created_at - a.created_at) .slice(0, MAX_POSTS_PER_AUTHOR) next.set(pk, sorted) } return next } function recommendedCuratorHexPubkey(): string | null { try { const dec = nip19.decode(RECOMMENDED_FOLLOW_CURATOR_NPUB) if (dec.type !== 'npub') return null return dec.data } catch { return null } } export default function LatestFromFollowsSection({ refreshKey = 0, variant = 'embedded' }: { /** Bump to re-run batched relay fetches (e.g. titlebar / page refresh). */ refreshKey?: number /** `page`: full-width list on the follows-latest primary page; `embedded`: tighter vertical spacing. */ variant?: 'page' | 'embedded' } = {}) { const { t } = useTranslation() const { push } = useSecondaryPage() const { pubkey, followListEvent, isInitialized, isAccountSessionHydrating } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { mutePubkeySet } = useMuteList() const { isEventDeleted } = useDeletedEvent() const { hideUntrustedNotes, isUserTrusted } = useUserTrust() const loggedInFollowPubkeys = useMemo(() => { if (!pubkey || !isInitialized) return null return getPubkeysFromPTags(followListEvent?.tags ?? []).slice(0, MAX_FOLLOWS) }, [pubkey, isInitialized, followListEvent]) const [guestFollowPubkeys, setGuestFollowPubkeys] = useState([]) const [guestListReady, setGuestListReady] = useState(false) const [postsByPubkey, setPostsByPubkey] = useState>(() => new Map()) const [batchBusy, setBatchBusy] = useState(false) const abortedRef = useRef(false) const followPubkeys = pubkey ? (loggedInFollowPubkeys ?? []) : guestFollowPubkeys const followsLabel: 'self' | 'recommended' = pubkey ? 'self' : 'recommended' const [followListGraceExpired, setFollowListGraceExpired] = useState(false) useEffect(() => { if (!pubkey || followListEvent) { setFollowListGraceExpired(false) return } const t = setTimeout(() => setFollowListGraceExpired(true), 4000) return () => clearTimeout(t) }, [pubkey, followListEvent]) const loadingFollowList = (!pubkey && isInitialized && !guestListReady) || (!!pubkey && !followListEvent && (isAccountSessionHydrating || !followListGraceExpired)) const [aggregateRelayUrls, setAggregateRelayUrls] = useState([]) const [aggregateRelaysReady, setAggregateRelaysReady] = useState(false) const followListFingerprint = useMemo( () => fingerprintSortedPubkeys(followPubkeys), [followPubkeys] ) const aggregateRelayFingerprint = useMemo( () => fingerprintRelaySet(aggregateRelayUrls), [aggregateRelayUrls] ) const followsFeedScopeKey = useMemo( () => buildSearchFollowsFeedScopeKey({ mode: followsLabel, viewerPubkey: pubkey?.toLowerCase() ?? null, followListFingerprint, aggregateRelayFingerprint }), [followsLabel, pubkey, followListFingerprint, aggregateRelayFingerprint] ) const acceptEvent = useCallback( (e: Event) => { if (!feedKindSet.has(e.kind)) return false if (isEventDeleted(e)) return false if (shouldFilterEvent(e)) return false if (muteSetHas(mutePubkeySet, e.pubkey)) return false if (hideUntrustedNotes && !isUserTrusted(e.pubkey)) return false return true }, [hideUntrustedNotes, isEventDeleted, isUserTrusted, mutePubkeySet] ) // Guest: load curated follow list from npub; logged-in list comes from useMemo above. useEffect(() => { if (!isInitialized) return if (pubkey) { setGuestFollowPubkeys([]) setGuestListReady(false) return } let cancelled = false setGuestListReady(false) setGuestFollowPubkeys([]) ;(async () => { logger.info(`${LOG} guest: loading recommended follow list`) const hex = recommendedCuratorHexPubkey() if (!hex) { if (!cancelled) { setGuestFollowPubkeys([]) setGuestListReady(true) logger.info(`${LOG} guest: no curator npub; follow list empty`) } return } try { const evt = await replaceableEventService.fetchReplaceableEvent(hex, kinds.Contacts) if (cancelled) return const list = evt ? getPubkeysFromPTags(evt.tags).slice(0, MAX_FOLLOWS) : [] setGuestFollowPubkeys(list) logger.info(`${LOG} guest: follow list loaded`, { count: list.length }) } catch (err) { logger.warn('[LatestFromFollows] Failed to load recommended follow list', err) if (!cancelled) setGuestFollowPubkeys([]) } finally { if (!cancelled) setGuestListReady(true) } })() return () => { cancelled = true } }, [isInitialized, pubkey]) // Load each follow's NIP-65 list (IndexedDB + network), then aggregate first outboxes + READ_ONLY relays. useEffect(() => { if (!isInitialized || loadingFollowList) { logger.info(`${LOG} relays: waiting`, { isInitialized, loadingFollowList, variant, followsLabel }) return } if (followPubkeys.length === 0) { logger.info(`${LOG} relays: no follows; skipping aggregate`) setAggregateRelayUrls([]) setAggregateRelaysReady(true) return } let cancelled = false setAggregateRelaysReady(false) setAggregateRelayUrls([]) ;(async () => { logger.info(`${LOG} relays: fetch NIP-65 lists start`, { authorCount: followPubkeys.length, variant, followsLabel }) 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, favoriteRelays ) setAggregateRelayUrls(urls) logger.info(`${LOG} relays: aggregate URLs computed → setState`, { nip65ListsLoaded: allLists.length, aggregateUrlCount: urls.length, relaySample: urls.slice(0, 6) }) } catch (err) { logger.warn('[LatestFromFollows] Failed to build follow outbox aggregate relays', err) if (!cancelled) { const fallback = buildFollowOutboxAggregateReadUrls([], blockedRelays, favoriteRelays) setAggregateRelayUrls(fallback) logger.info(`${LOG} relays: using fallback aggregate URLs after error`, { aggregateUrlCount: fallback.length }) } } finally { if (!cancelled) { setAggregateRelaysReady(true) logger.info(`${LOG} relays: aggregateRelaysReady → true`) } } })() return () => { cancelled = true } }, [followPubkeys, favoriteRelays, blockedRelays, isInitialized, loadingFollowList, variant, followsLabel]) // Batch-fetch posts per slice of authors against the aggregate relay set. useEffect(() => { if (!isInitialized || loadingFollowList) { logger.info(`${LOG} posts: waiting`, { isInitialized, loadingFollowList, aggregateRelaysReady, followCount: followPubkeys.length, variant }) return } if (followPubkeys.length === 0) { logger.info(`${LOG} posts: no follows; skipping batch fetch`) return } if (!aggregateRelaysReady) { logger.info(`${LOG} posts: waiting for aggregate relays`) return } abortedRef.current = false let cancelled = false const run = async () => { setBatchBusy(true) const seed = readSearchFollowsFeedCache(followsFeedScopeKey) let working = seed ? postsRecordToMap(seed.posts) : new Map() setPostsByPubkey(new Map(working)) const summarizePosts = (m: Map) => { let authorsWithPosts = 0 let totalNotes = 0 for (const arr of m.values()) { if (arr.length > 0) authorsWithPosts++ totalNotes += arr.length } return { authorsWithPosts, totalNotes, mapKeyCount: m.size } } logger.info(`${LOG} posts: batch run start`, { followCount: followPubkeys.length, relayUrlCount: aggregateRelayUrls.length, hideUntrustedNotes, usedCacheSeed: Boolean(seed), ...summarizePosts(working) }) const persist = () => { writeSearchFollowsFeedCache({ v: 1, scopeKey: followsFeedScopeKey, posts: postsMapToRecord(working), savedAtMs: Date.now() }) } const batchCount = Math.ceil(followPubkeys.length / AUTHORS_PER_BATCH) for (let i = 0; i < followPubkeys.length; i += AUTHORS_PER_BATCH) { if (cancelled || abortedRef.current) break const batch = followPubkeys.slice(i, i + AUTHORS_PER_BATCH) const batchIndex = Math.floor(i / AUTHORS_PER_BATCH) + 1 try { logger.info(`${LOG} posts: REQ batch ${batchIndex}/${batchCount}`, { authorBatchSize: batch.length, kinds: FEED_KINDS.length, limit: BATCH_EVENT_LIMIT }) const t0 = performance.now() const raw = await queryService.fetchEvents( aggregateRelayUrls, { kinds: [...FEED_KINDS], authors: batch, limit: BATCH_EVENT_LIMIT }, { eoseTimeout: 2800, globalTimeout: 9000 } ) const ms = Math.round(performance.now() - t0) if (cancelled || abortedRef.current) break const filtered = raw.filter((e) => acceptEvent(e)) working = mergeBatchPosts(working, filtered, batch) setPostsByPubkey(new Map(working)) persist() logger.info(`${LOG} posts: batch ${batchIndex}/${batchCount} done + UI setPostsByPubkey`, { ms, rawFromRelays: raw.length, afterAcceptFilter: filtered.length, droppedByAccept: raw.length - filtered.length, ...summarizePosts(working) }) } catch (err) { logger.warn('[LatestFromFollows] Batch fetch failed', { err, batchSize: batch.length }) } } if (!cancelled) { persist() setBatchBusy(false) logger.info(`${LOG} posts: batch run finished`, { cancelled: false, ...summarizePosts(working) }) } } void run() return () => { cancelled = true abortedRef.current = true setBatchBusy(false) logger.info(`${LOG} posts: batch effect cleanup (cancelled / deps changed)`) } }, [ followPubkeys, aggregateRelayUrls, aggregateRelaysReady, loadingFollowList, isInitialized, acceptEvent, followsFeedScopeKey, refreshKey ]) const sortedRowPubkeys = useMemo(() => { const withPosts = followPubkeys.filter((pk) => (postsByPubkey.get(pk)?.length ?? 0) > 0) const withoutPosts = followPubkeys.filter((pk) => (postsByPubkey.get(pk)?.length ?? 0) === 0) withPosts.sort((a, b) => { const ta = postsByPubkey.get(a)?.[0]?.created_at ?? 0 const tb = postsByPubkey.get(b)?.[0]?.created_at ?? 0 return tb - ta }) return [...withPosts, ...withoutPosts] }, [followPubkeys, postsByPubkey]) const vertical = variant === 'page' ? '' : 'mb-6' if (!isInitialized) { return null } if (loadingFollowList) { return (
) } if (followPubkeys.length === 0) { return (
{followsLabel === 'recommended' ? t('Could not load recommended follows') : t('Your follow list is empty')}
) } return (
{batchBusy && postsByPubkey.size === 0 ? (
{Array.from({ length: 4 }).map((_, i) => ( ))}
) : null} {sortedRowPubkeys.map((pk) => { const posts = postsByPubkey.get(pk) ?? [] const count = posts.length const latest = posts[0]?.created_at return ( push(toProfile(pk))} /> ) })} {batchBusy && postsByPubkey.size > 0 ? (
) : null}
) } function FollowRowEmptyPosts() { const { t } = useTranslation() return (
{t('No recent posts from this user in the current fetch')}
) } function FollowPulseRow({ pubkey, count, latestCreatedAt, posts, onOpenProfile }: { pubkey: string count: number latestCreatedAt?: number posts: NostrEvent[] onOpenProfile: () => void }) { const [open, setOpen] = useState(false) return (
{posts.length === 0 ? ( ) : (
{posts.map((ev) => ( ))}
)}
) }