13 changed files with 50 additions and 1147 deletions
@ -1,575 +0,0 @@
@@ -1,575 +0,0 @@
|
||||
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> |
||||
) |
||||
} |
||||
@ -1,151 +0,0 @@
@@ -1,151 +0,0 @@
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' |
||||
import { Button } from '@/components/ui/button' |
||||
import { Skeleton } from '@/components/ui/skeleton' |
||||
import { ChevronDown, RefreshCw } from 'lucide-react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import { cn } from '@/lib/utils' |
||||
import { useProfileRelayUrls } from '@/hooks/useProfileRelayUrls' |
||||
import { useProfileAccordionData } from '@/hooks/useProfileAccordionData' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import ProfileHeaderInteractions from './ProfileHeaderInteractions' |
||||
|
||||
type Props = { |
||||
pubkey: string | undefined |
||||
isExpanded: boolean |
||||
onExpandedChange: (open: boolean) => void |
||||
} |
||||
|
||||
function ProfileInteractionsSkeleton() { |
||||
return ( |
||||
<div className="py-2 space-y-3"> |
||||
{[6, 4, 4, 8, 6, 6].map((count, i) => ( |
||||
<div key={i} className="min-w-0"> |
||||
<Skeleton className="h-3 w-16 mb-1.5" /> |
||||
<div |
||||
className={ |
||||
i === 3 |
||||
? 'grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-7 gap-1' |
||||
: 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5' |
||||
} |
||||
> |
||||
{Array.from({ length: count }).map((_, j) => ( |
||||
<Skeleton |
||||
key={j} |
||||
className={cn('rounded-lg min-w-0', i === 3 ? 'aspect-square h-24 w-full' : 'h-8')} |
||||
/> |
||||
))} |
||||
</div> |
||||
</div> |
||||
))} |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
export default function ProfileInteractionsAccordion({ |
||||
pubkey, |
||||
isExpanded, |
||||
onExpandedChange |
||||
}: Props) { |
||||
const { t } = useTranslation() |
||||
const { pubkey: viewerPubkey } = useNostr() |
||||
const { relayUrls, loading: relayUrlsLoading, refresh: refreshRelayUrls } = useProfileRelayUrls( |
||||
pubkey, |
||||
isExpanded |
||||
) |
||||
const relaysReady = !relayUrlsLoading |
||||
const urlsForFetch = relayUrls.length > 0 ? relayUrls : undefined |
||||
|
||||
const { |
||||
zaps, |
||||
reactions, |
||||
comments, |
||||
badges, |
||||
followPacks, |
||||
reports, |
||||
loading: bundleLoading, |
||||
refresh: refreshBundle |
||||
} = useProfileAccordionData({ |
||||
pubkey, |
||||
relayUrls: urlsForFetch, |
||||
enabled: isExpanded && relaysReady && !!pubkey, |
||||
viewerPubkey |
||||
}) |
||||
|
||||
const handleRefresh = () => { |
||||
void (async () => { |
||||
const urls = await refreshRelayUrls() |
||||
refreshBundle(urls.length > 0 ? urls : undefined) |
||||
})() |
||||
} |
||||
|
||||
const hasContent = isExpanded && pubkey |
||||
const hasAnyBundleData = |
||||
zaps.length > 0 || |
||||
reactions.length > 0 || |
||||
comments.length > 0 || |
||||
badges.length > 0 || |
||||
followPacks.length > 0 || |
||||
reports.length > 0 |
||||
const showSkeleton = hasContent && (!relaysReady || (bundleLoading && !hasAnyBundleData)) |
||||
|
||||
return ( |
||||
<Collapsible open={isExpanded} onOpenChange={onExpandedChange} className="min-w-0"> |
||||
<div className="flex min-w-0 items-stretch gap-1 rounded-lg border border-border/80 bg-muted/15 hover:bg-muted/25"> |
||||
<CollapsibleTrigger className="flex min-w-0 flex-1 items-center justify-between gap-2 px-3 py-2 text-left"> |
||||
<span className="text-sm font-medium truncate"> |
||||
{t('Zaps')}, {t('Likes')}, {t('Comments')}, {t('Badges')}, {t('In Follow Packs')}, {t('Reports')} |
||||
</span> |
||||
<ChevronDown |
||||
className={cn( |
||||
'size-4 shrink-0 text-muted-foreground transition-transform', |
||||
isExpanded && 'rotate-180' |
||||
)} |
||||
/> |
||||
</CollapsibleTrigger> |
||||
<Button |
||||
type="button" |
||||
variant="ghost" |
||||
size="icon" |
||||
className="my-1 mr-1 shrink-0 rounded-md" |
||||
title={t('Refresh')} |
||||
aria-label={t('Refresh')} |
||||
disabled={!pubkey} |
||||
onClick={(e) => { |
||||
e.preventDefault() |
||||
e.stopPropagation() |
||||
handleRefresh() |
||||
}} |
||||
> |
||||
<RefreshCw className={cn('size-4', bundleLoading && 'animate-spin')} /> |
||||
</Button> |
||||
</div> |
||||
<CollapsibleContent className="overflow-hidden"> |
||||
{hasContent ? ( |
||||
showSkeleton ? ( |
||||
<div className="pt-2"> |
||||
<ProfileInteractionsSkeleton /> |
||||
</div> |
||||
) : ( |
||||
<div className="pt-2"> |
||||
<ProfileHeaderInteractions |
||||
profilePubkey={pubkey} |
||||
badgeRelayUrls={relayUrls} |
||||
zaps={zaps} |
||||
reactions={reactions} |
||||
comments={comments} |
||||
badges={badges} |
||||
followPacks={followPacks} |
||||
reports={reports} |
||||
loading={bundleLoading} |
||||
badgesLoading={bundleLoading} |
||||
followPacksLoading={bundleLoading} |
||||
reportsLoading={bundleLoading} |
||||
reportsEnabled={!!viewerPubkey} |
||||
/> |
||||
</div> |
||||
) |
||||
) : null} |
||||
</CollapsibleContent> |
||||
</Collapsible> |
||||
) |
||||
} |
||||
@ -1,25 +0,0 @@
@@ -1,25 +0,0 @@
|
||||
import { usePrimaryPage } from '@/contexts/primary-page-context' |
||||
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import { UsersRound } from 'lucide-react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import SidebarItem from './SidebarItem' |
||||
|
||||
export default function FollowsLatestButton() { |
||||
const { t } = useTranslation() |
||||
const { navigate, current, display } = usePrimaryPage() |
||||
const { primaryViewType } = usePrimaryNoteView() |
||||
const { pubkey } = useNostr() |
||||
|
||||
if (!pubkey) return null |
||||
|
||||
return ( |
||||
<SidebarItem |
||||
title={t('Follows latest nav label')} |
||||
onClick={() => navigate('follows-latest')} |
||||
active={current === 'follows-latest' && display && primaryViewType === null} |
||||
> |
||||
<UsersRound strokeWidth={2.5} /> |
||||
</SidebarItem> |
||||
) |
||||
} |
||||
@ -1,210 +0,0 @@
@@ -1,210 +0,0 @@
|
||||
import { |
||||
fetchProfileAccordionBundle, |
||||
mergeProfileAccordionBundles, |
||||
profileAccordionBundleCacheKey, |
||||
type ProfileAccordionBundle |
||||
} from '@/lib/profile-accordion-fetch' |
||||
import { |
||||
profileAccordionGetCachedBadges, |
||||
profileAccordionGetCachedFollowPacks, |
||||
profileAccordionGetCachedInteractions, |
||||
profileAccordionGetCachedReports, |
||||
profileAccordionRelayUrlsKey, |
||||
profileAccordionSetBadges, |
||||
profileAccordionSetFollowPacks, |
||||
profileAccordionSetInteractions, |
||||
profileAccordionSetReports |
||||
} from '@/lib/profile-accordion-session-cache' |
||||
import { subtractNormalizedRelayUrls } from '@/lib/url' |
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' |
||||
|
||||
const EMPTY: ProfileAccordionBundle = { |
||||
zaps: [], |
||||
reactions: [], |
||||
comments: [], |
||||
badges: [], |
||||
followPacks: [], |
||||
reports: [] |
||||
} |
||||
|
||||
function readFullCache( |
||||
pubkey: string, |
||||
relayKey: string, |
||||
viewerPubkey: string | null | undefined |
||||
): ProfileAccordionBundle | null { |
||||
const zi = profileAccordionGetCachedInteractions(pubkey, relayKey) |
||||
const zb = profileAccordionGetCachedBadges(pubkey, relayKey) |
||||
const zf = profileAccordionGetCachedFollowPacks(pubkey, relayKey) |
||||
const viewer = viewerPubkey?.trim() |
||||
const reportsReady = !viewer || profileAccordionGetCachedReports(pubkey, viewer) !== undefined |
||||
if (!zi || zb === undefined || zf === undefined || !reportsReady) return null |
||||
const reports = |
||||
viewer ? profileAccordionGetCachedReports(pubkey, viewer) ?? [] : [] |
||||
return { |
||||
zaps: zi.zaps, |
||||
reactions: zi.reactions, |
||||
comments: zi.comments, |
||||
badges: zb, |
||||
followPacks: zf, |
||||
reports |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Loads profile accordion data only when `enabled` (accordion open); hydrates from session cache first. |
||||
* Use {@link refresh} for manual network refresh. |
||||
*/ |
||||
export function useProfileAccordionData(opts: { |
||||
pubkey: string | undefined |
||||
relayUrls: string[] | undefined |
||||
enabled: boolean |
||||
viewerPubkey: string | null | undefined |
||||
}) { |
||||
const { pubkey, relayUrls, enabled, viewerPubkey } = opts |
||||
const { favoriteRelays, blockedRelays } = useFavoriteRelays() |
||||
const [data, setData] = useState<ProfileAccordionBundle>(EMPTY) |
||||
const [loading, setLoading] = useState(false) |
||||
const reqId = useRef(0) |
||||
const lastSuccessfulRelayUrlsRef = useRef<string[]>([]) |
||||
|
||||
// Keep refs so callbacks don't get recreated when these arrays change reference.
|
||||
// Including live array references as useCallback deps causes the useLayoutEffect
|
||||
// to re-fire and increment reqId, cancelling every in-flight fetch before it
|
||||
// can commit its result — the accordion never shows data.
|
||||
const relayUrlsRef = useRef(relayUrls) |
||||
relayUrlsRef.current = relayUrls |
||||
const favoriteRelaysRef = useRef(favoriteRelays) |
||||
favoriteRelaysRef.current = favoriteRelays |
||||
const blockedRelaysRef = useRef(blockedRelays) |
||||
blockedRelaysRef.current = blockedRelays |
||||
|
||||
const relayKey = useMemo( |
||||
() => profileAccordionBundleCacheKey(relayUrls ?? []), |
||||
[relayUrls] |
||||
) |
||||
|
||||
useEffect(() => { |
||||
lastSuccessfulRelayUrlsRef.current = [] |
||||
}, [pubkey]) |
||||
|
||||
const runFetch = useCallback( |
||||
async (force: boolean, overrideUrls?: string[]) => { |
||||
const urls = (overrideUrls?.length ? overrideUrls : relayUrlsRef.current) ?? [] |
||||
if (!pubkey?.trim() || !urls.length) return |
||||
const id = ++reqId.current |
||||
setLoading(true) |
||||
try { |
||||
const bundle = await fetchProfileAccordionBundle({ |
||||
pubkey: pubkey.trim(), |
||||
urls, |
||||
viewerPubkey, |
||||
favoriteRelays: favoriteRelaysRef.current ?? [], |
||||
blockedRelays: blockedRelaysRef.current, |
||||
force, |
||||
onPartial: (partial) => { |
||||
if (id !== reqId.current) return |
||||
setData(partial) |
||||
} |
||||
}) |
||||
if (id !== reqId.current) return |
||||
setData(bundle) |
||||
lastSuccessfulRelayUrlsRef.current = urls |
||||
} finally { |
||||
if (id === reqId.current) setLoading(false) |
||||
} |
||||
}, |
||||
// relayUrls, favoriteRelays, and blockedRelays are read via refs — intentionally
|
||||
// excluded from deps to prevent callback churn that cancels in-flight requests.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[pubkey, viewerPubkey] |
||||
) |
||||
|
||||
const runMergeFetch = useCallback( |
||||
async (fullRelayUrls: string[], deltaUrls: string[], base: ProfileAccordionBundle) => { |
||||
const pk = pubkey?.trim() |
||||
if (!pk || !deltaUrls.length) return |
||||
const id = ++reqId.current |
||||
setLoading(true) |
||||
try { |
||||
const deltaB = await fetchProfileAccordionBundle({ |
||||
pubkey: pk, |
||||
urls: deltaUrls, |
||||
viewerPubkey, |
||||
favoriteRelays: favoriteRelaysRef.current ?? [], |
||||
blockedRelays: blockedRelaysRef.current, |
||||
force: true, |
||||
onPartial: (partial) => { |
||||
if (id !== reqId.current) return |
||||
setData(mergeProfileAccordionBundles(base, partial)) |
||||
} |
||||
}) |
||||
if (id !== reqId.current) return |
||||
const merged = mergeProfileAccordionBundles(base, deltaB) |
||||
setData(merged) |
||||
const fullKey = profileAccordionBundleCacheKey(fullRelayUrls) |
||||
profileAccordionSetInteractions(pk, fullKey, { |
||||
zaps: merged.zaps, |
||||
reactions: merged.reactions, |
||||
comments: merged.comments |
||||
}) |
||||
profileAccordionSetBadges(pk, fullKey, merged.badges) |
||||
profileAccordionSetFollowPacks(pk, fullKey, merged.followPacks) |
||||
const viewer = viewerPubkey?.trim() |
||||
if (viewer) profileAccordionSetReports(pk, viewer, merged.reports) |
||||
lastSuccessfulRelayUrlsRef.current = fullRelayUrls |
||||
} finally { |
||||
if (id === reqId.current) setLoading(false) |
||||
} |
||||
}, |
||||
// favoriteRelays and blockedRelays are read via refs — see runFetch comment.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[pubkey, viewerPubkey] |
||||
) |
||||
|
||||
const refresh = useCallback( |
||||
(overrideUrls?: string[]) => { |
||||
void runFetch(true, overrideUrls) |
||||
}, |
||||
[runFetch] |
||||
) |
||||
|
||||
useLayoutEffect(() => { |
||||
if (!enabled || !pubkey?.trim() || !relayUrls?.length) { |
||||
return |
||||
} |
||||
const pk = pubkey.trim() |
||||
const cached = readFullCache(pk, relayKey, viewerPubkey) |
||||
if (cached) { |
||||
setData(cached) |
||||
setLoading(false) |
||||
lastSuccessfulRelayUrlsRef.current = relayUrls |
||||
return |
||||
} |
||||
|
||||
const prevSucc = lastSuccessfulRelayUrlsRef.current |
||||
if ( |
||||
prevSucc.length > 0 && |
||||
profileAccordionRelayUrlsKey(prevSucc) !== profileAccordionRelayUrlsKey(relayUrls) |
||||
) { |
||||
const delta = subtractNormalizedRelayUrls(relayUrls, prevSucc) |
||||
if (delta.length > 0) { |
||||
const prevKey = profileAccordionBundleCacheKey(prevSucc) |
||||
const base = readFullCache(pk, prevKey, viewerPubkey) |
||||
if (base) { |
||||
void runMergeFetch(relayUrls, delta, base) |
||||
return |
||||
} |
||||
} |
||||
} |
||||
|
||||
setLoading(true) |
||||
void runFetch(false) |
||||
}, [enabled, pubkey, relayKey, relayUrls, viewerPubkey, runFetch, runMergeFetch]) |
||||
|
||||
return { |
||||
...data, |
||||
loading, |
||||
refresh |
||||
} |
||||
} |
||||
@ -1,58 +0,0 @@
@@ -1,58 +0,0 @@
|
||||
import LatestFromFollowsSection from '@/components/LatestFromFollowsSection' |
||||
import { RefreshButton } from '@/components/RefreshButton' |
||||
import PrimaryPageLayout, { TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout' |
||||
import { TPageRef } from '@/types' |
||||
import { UsersRound } from 'lucide-react' |
||||
import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
const FollowsLatestPage = forwardRef<TPageRef>(function FollowsLatestPage(_, ref) { |
||||
const { t } = useTranslation() |
||||
const [refreshKey, setRefreshKey] = useState(0) |
||||
const layoutRef = useRef<TPrimaryPageLayoutRef>(null) |
||||
|
||||
const bumpRefresh = useCallback(() => { |
||||
setRefreshKey((k) => k + 1) |
||||
}, []) |
||||
|
||||
useImperativeHandle( |
||||
ref, |
||||
() => ({ |
||||
scrollToTop: (behavior: ScrollBehavior = 'smooth') => layoutRef.current?.scrollToTop(behavior), |
||||
refresh: bumpRefresh |
||||
}), |
||||
[bumpRefresh] |
||||
) |
||||
|
||||
return ( |
||||
<PrimaryPageLayout |
||||
ref={layoutRef} |
||||
pageName="follows-latest" |
||||
titlebar={<FollowsLatestPageTitlebar onRefresh={bumpRefresh} />} |
||||
displayScrollToTopButton |
||||
> |
||||
<div className="min-w-0 pt-4 px-4 pb-8"> |
||||
<p className="mb-4 max-w-prose text-sm text-muted-foreground leading-relaxed"> |
||||
{t('Follows latest page description')} |
||||
</p> |
||||
<LatestFromFollowsSection refreshKey={refreshKey} variant="page" /> |
||||
</div> |
||||
</PrimaryPageLayout> |
||||
) |
||||
}) |
||||
|
||||
FollowsLatestPage.displayName = 'FollowsLatestPage' |
||||
export default FollowsLatestPage |
||||
|
||||
function FollowsLatestPageTitlebar({ onRefresh }: { onRefresh: () => void }) { |
||||
const { t } = useTranslation() |
||||
return ( |
||||
<div className="flex h-full w-full items-center justify-between gap-2 pr-1"> |
||||
<div className="flex items-center gap-2 pl-3"> |
||||
<UsersRound className="size-5" /> |
||||
<div className="app-chrome-title">{t('Follows latest page title')}</div> |
||||
</div> |
||||
<RefreshButton onClick={onRefresh} /> |
||||
</div> |
||||
) |
||||
} |
||||
Loading…
Reference in new issue