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
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> |
|
) |
|
}
|
|
|