diff --git a/app/routes/subscriptions.tsx b/app/routes/subscriptions.tsx index 852ad9a..f527b96 100644 --- a/app/routes/subscriptions.tsx +++ b/app/routes/subscriptions.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react' import { useAuth } from '~/lib/auth-context' import { useData } from '~/lib/data-context' import { listPublicPublications, unsubscribe, type Publication } from '~/lib/api-client' +import type { Subscription } from '~/lib/api-client' import CenteredMessage from '~/components/CenteredMessage' import EmptyState from '~/components/EmptyState' import PageLayout from '~/components/PageLayout' @@ -12,75 +13,121 @@ export function meta() { return [{ title: `Subscriptions — ${SITE_NAME}` }] } +const PAGE_SIZE = 20 +const BATCH = 5 + type SubscribedPub = Publication & { rkey: string } +function buildByAuthor(subs: Subscription[]) { + const map = new Map() + for (const s of subs) { + const list = map.get(s.authorDid) ?? [] + list.push({ rkey: s.rkey, publicationAtUri: s.publicationAtUri }) + map.set(s.authorDid, list) + } + return map +} + +async function resolvePubs( + byAuthor: Map, + cancelled: { current: boolean }, +): Promise { + const authors = [...byAuthor.entries()] + const result: SubscribedPub[] = [] + for (let i = 0; i < authors.length; i += BATCH) { + if (cancelled.current) return result + const settled = await Promise.allSettled( + authors.slice(i, i + BATCH).map(async ([did, entries]) => { + const pubList = await listPublicPublications(did) + return entries.flatMap(({ rkey, publicationAtUri }) => { + const pub = pubList.find((p) => p.atUri === publicationAtUri) + return pub ? [{ ...pub, rkey }] : [] + }) + }), + ) + for (const r of settled) { + if (r.status === 'fulfilled') result.push(...r.value) + } + } + return result +} + export default function Subscriptions() { const { session, loading: authLoading } = useAuth() const { subscriptions, removeSubscription } = useData() - const [pubs, setPubs] = useState(null) + const [pubs, setPubs] = useState([]) + const [offset, setOffset] = useState(0) const [loading, setLoading] = useState(false) + const [hasMore, setHasMore] = useState(false) const [error, setError] = useState(null) const [loadingPubs, setLoadingPubs] = useState>({}) - const loadedRef = useRef(false) + const initializedRef = useRef(false) + const sentinelRef = useRef(null) + // First page — fires once when subscriptions become available in context useEffect(() => { - // subscriptions undefined means DataProvider hasn't finished loading yet - if (subscriptions === undefined || loadedRef.current) return - loadedRef.current = true - - if (subscriptions.length === 0) { - setPubs([]) - return - } + if (subscriptions === undefined || initializedRef.current) return + initializedRef.current = true + if (subscriptions.length === 0) return - let cancelled = false + const cancelled = { current: false } setLoading(true) - - const byAuthor = new Map() - for (const s of subscriptions) { - const list = byAuthor.get(s.authorDid) ?? [] - list.push({ rkey: s.rkey, publicationAtUri: s.publicationAtUri }) - byAuthor.set(s.authorDid, list) - } - - const authors = [...byAuthor.entries()] - const BATCH = 5 - const allResults: SubscribedPub[] = [] - - async function fetchBatched() { - for (let i = 0; i < authors.length; i += BATCH) { - if (cancelled) return - const batch = authors.slice(i, i + BATCH) - const results = await Promise.allSettled( - batch.map(async ([did, entries]) => { - const pubList = await listPublicPublications(did) - return entries.flatMap(({ rkey, publicationAtUri }) => { - const pub = pubList.find((p) => p.atUri === publicationAtUri) - return pub ? [{ ...pub, rkey }] : [] - }) - }), - ) - for (const r of results) { - if (r.status === 'fulfilled') allResults.push(...r.value) - } - } - if (!cancelled) setPubs(allResults) - } - - fetchBatched() - .catch((err) => { if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load') }) - .finally(() => { if (!cancelled) setLoading(false) }) - - return () => { cancelled = true } + const slice = subscriptions.slice(0, PAGE_SIZE) + + resolvePubs(buildByAuthor(slice), cancelled) + .then((newPubs) => { + if (cancelled.current) return + setPubs(newPubs) + setOffset(slice.length) + setHasMore(slice.length < subscriptions.length) + }) + .catch((err) => { if (!cancelled.current) setError(err instanceof Error ? err.message : 'Failed to load') }) + .finally(() => { if (!cancelled.current) setLoading(false) }) + + return () => { cancelled.current = true } }, [subscriptions]) + // Subsequent pages — IntersectionObserver on the sentinel + useEffect(() => { + if (!hasMore || loading || !sentinelRef.current || !subscriptions) return + const sentinel = sentinelRef.current + // Capture current values so the callback sees the right slice + const currentOffset = offset + const currentSubs = subscriptions + + const observer = new IntersectionObserver((entries) => { + if (!entries[0].isIntersecting) return + observer.disconnect() + + const slice = currentSubs.slice(currentOffset, currentOffset + PAGE_SIZE) + if (slice.length === 0) { setHasMore(false); return } + + const cancelled = { current: false } + setLoading(true) + + resolvePubs(buildByAuthor(slice), cancelled) + .then((newPubs) => { + if (cancelled.current) return + setPubs((prev) => [...prev, ...newPubs]) + const nextOffset = currentOffset + slice.length + setOffset(nextOffset) + setHasMore(nextOffset < currentSubs.length) + }) + .catch((err) => { if (!cancelled.current) setError(err instanceof Error ? err.message : 'Failed to load more') }) + .finally(() => { if (!cancelled.current) setLoading(false) }) + }) + + observer.observe(sentinel) + return () => observer.disconnect() + }, [hasMore, loading, offset, subscriptions]) + async function handleUnsubscribe(pub: SubscribedPub) { if (!session) return setLoadingPubs((prev) => ({ ...prev, [pub.atUri]: true })) try { await unsubscribe(session, pub.rkey) removeSubscription(pub.rkey) - setPubs((prev) => prev?.filter((p) => p.atUri !== pub.atUri) ?? prev) + setPubs((prev) => prev.filter((p) => p.atUri !== pub.atUri)) } catch { // non-fatal } finally { @@ -109,20 +156,20 @@ export default function Subscriptions() { {error &&

{error}

} - {loading && ( + {loading && pubs.length === 0 && (
)} - {!loading && pubs !== null && pubs.length === 0 && ( + {subscriptions !== undefined && subscriptions.length === 0 && ( )} - {pubs !== null && pubs.length > 0 && ( + {pubs.length > 0 && (
    {pubs.map((pub) => { const isLoading = !!loadingPubs[pub.atUri] @@ -150,6 +197,20 @@ export default function Subscriptions() { })}
)} + +
+ + {loading && pubs.length > 0 && ( +
+ +
+ )} + + {!hasMore && !loading && pubs.length > 0 && ( +

+ You've reached the end. +

+ )} ) }