You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

575 lines
19 KiB

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<string, NostrEvent[]>,
incoming: NostrEvent[],
batchAuthors: string[]
): Map<string, NostrEvent[]> {
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<string, NostrEvent>()
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<string[]>([])
const [guestListReady, setGuestListReady] = useState(false)
const [postsByPubkey, setPostsByPubkey] = useState<Map<string, NostrEvent[]>>(() => 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<string[]>([])
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<string, NostrEvent[]>()
setPostsByPubkey(new Map(working))
const summarizePosts = (m: Map<string, NostrEvent[]>) => {
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 (
<div className={cn('space-y-2', vertical)} role="status" aria-busy="true" aria-live="polite">
<Skeleton className="h-4 w-56 max-w-full" />
<Skeleton className="h-4 w-72 max-w-full" />
</div>
)
}
if (followPubkeys.length === 0) {
return (
<div
className={cn(
'rounded-lg border border-border/80 bg-muted/20 px-4 py-3 text-sm text-muted-foreground',
vertical
)}
>
{followsLabel === 'recommended'
? t('Could not load recommended follows')
: t('Your follow list is empty')}
</div>
)
}
return (
<div className="min-w-0 space-y-0 rounded-lg border border-border/60 overflow-hidden">
{batchBusy && postsByPubkey.size === 0 ? (
<div className="space-y-2 px-4 py-4" role="status" aria-busy="true" aria-live="polite">
<Skeleton className="h-3 w-64 max-w-full" />
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-md" />
))}
</div>
) : null}
{sortedRowPubkeys.map((pk) => {
const posts = postsByPubkey.get(pk) ?? []
const count = posts.length
const latest = posts[0]?.created_at
return (
<FollowPulseRow
key={pk}
pubkey={pk}
count={count}
latestCreatedAt={latest}
posts={posts}
onOpenProfile={() => push(toProfile(pk))}
/>
)
})}
{batchBusy && postsByPubkey.size > 0 ? (
<div className="px-4 py-2 border-t border-border/50">
<Skeleton className="h-3 w-28" aria-hidden />
</div>
) : null}
</div>
)
}
function FollowRowEmptyPosts() {
const { t } = useTranslation()
return (
<div className="px-4 py-3 text-sm text-muted-foreground">
{t('No recent posts from this user in the current fetch')}
</div>
)
}
function FollowPulseRow({
pubkey,
count,
latestCreatedAt,
posts,
onOpenProfile
}: {
pubkey: string
count: number
latestCreatedAt?: number
posts: NostrEvent[]
onOpenProfile: () => void
}) {
const [open, setOpen] = useState(false)
return (
<Collapsible open={open} onOpenChange={setOpen} className="border-b border-border/60 last:border-b-0">
<div className="flex items-stretch gap-0">
<CollapsibleTrigger
className="flex size-10 shrink-0 items-center justify-center border-r border-border/50 text-muted-foreground hover:bg-muted/40"
aria-label={open ? 'Collapse posts' : 'Expand posts'}
>
<ChevronRight className={cn('size-4 transition-transform', open && 'rotate-90')} />
</CollapsibleTrigger>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-left hover:bg-muted/30"
onClick={(e) => {
e.stopPropagation()
onOpenProfile()
}}
>
<UserAvatar userId={pubkey} size="medium" className="shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Username
userId={pubkey}
className="truncate text-sm font-semibold"
skeletonClassName="h-4 w-24"
/>
<Star className="size-3.5 shrink-0 text-muted-foreground/70" strokeWidth={1.5} aria-hidden />
</div>
<div className="text-xs text-muted-foreground">
{latestCreatedAt ? (
<FormattedTimestamp timestamp={latestCreatedAt} short />
) : (
'—'
)}
</div>
</div>
<div
className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/15 text-sm font-semibold text-primary tabular-nums"
title={String(count)}
>
{count}
</div>
</button>
</div>
<CollapsibleContent className="overflow-hidden border-t border-border/50 bg-muted/10">
{posts.length === 0 ? (
<FollowRowEmptyPosts />
) : (
<div className="pb-2">
{posts.map((ev) => (
<NoteCard key={ev.id} className="w-full" event={ev} />
))}
</div>
)}
</CollapsibleContent>
</Collapsible>
)
}