Blogging platform with advanced tools for arts and sciences.
6

Configure Feed

Select the types of activity you want to include in your feed.

Add infinite scroll to subscriptions

lemma (Jun 6, 2026, 10:38 AM -0500) 0c5d0af4 77d45897

+109 -48
+109 -48
app/routes/subscriptions.tsx
··· 2 2 import { useAuth } from '~/lib/auth-context' 3 3 import { useData } from '~/lib/data-context' 4 4 import { listPublicPublications, unsubscribe, type Publication } from '~/lib/api-client' 5 + import type { Subscription } from '~/lib/api-client' 5 6 import CenteredMessage from '~/components/CenteredMessage' 6 7 import EmptyState from '~/components/EmptyState' 7 8 import PageLayout from '~/components/PageLayout' ··· 12 13 return [{ title: `Subscriptions — ${SITE_NAME}` }] 13 14 } 14 15 16 + const PAGE_SIZE = 20 17 + const BATCH = 5 18 + 15 19 type SubscribedPub = Publication & { rkey: string } 16 20 21 + function buildByAuthor(subs: Subscription[]) { 22 + const map = new Map<string, { rkey: string; publicationAtUri: string }[]>() 23 + for (const s of subs) { 24 + const list = map.get(s.authorDid) ?? [] 25 + list.push({ rkey: s.rkey, publicationAtUri: s.publicationAtUri }) 26 + map.set(s.authorDid, list) 27 + } 28 + return map 29 + } 30 + 31 + async function resolvePubs( 32 + byAuthor: Map<string, { rkey: string; publicationAtUri: string }[]>, 33 + cancelled: { current: boolean }, 34 + ): Promise<SubscribedPub[]> { 35 + const authors = [...byAuthor.entries()] 36 + const result: SubscribedPub[] = [] 37 + for (let i = 0; i < authors.length; i += BATCH) { 38 + if (cancelled.current) return result 39 + const settled = await Promise.allSettled( 40 + authors.slice(i, i + BATCH).map(async ([did, entries]) => { 41 + const pubList = await listPublicPublications(did) 42 + return entries.flatMap(({ rkey, publicationAtUri }) => { 43 + const pub = pubList.find((p) => p.atUri === publicationAtUri) 44 + return pub ? [{ ...pub, rkey }] : [] 45 + }) 46 + }), 47 + ) 48 + for (const r of settled) { 49 + if (r.status === 'fulfilled') result.push(...r.value) 50 + } 51 + } 52 + return result 53 + } 54 + 17 55 export default function Subscriptions() { 18 56 const { session, loading: authLoading } = useAuth() 19 57 const { subscriptions, removeSubscription } = useData() 20 - const [pubs, setPubs] = useState<SubscribedPub[] | null>(null) 58 + const [pubs, setPubs] = useState<SubscribedPub[]>([]) 59 + const [offset, setOffset] = useState(0) 21 60 const [loading, setLoading] = useState(false) 61 + const [hasMore, setHasMore] = useState(false) 22 62 const [error, setError] = useState<string | null>(null) 23 63 const [loadingPubs, setLoadingPubs] = useState<Record<string, boolean>>({}) 24 - const loadedRef = useRef(false) 64 + const initializedRef = useRef(false) 65 + const sentinelRef = useRef<HTMLDivElement>(null) 25 66 67 + // First page — fires once when subscriptions become available in context 26 68 useEffect(() => { 27 - // subscriptions undefined means DataProvider hasn't finished loading yet 28 - if (subscriptions === undefined || loadedRef.current) return 29 - loadedRef.current = true 69 + if (subscriptions === undefined || initializedRef.current) return 70 + initializedRef.current = true 71 + if (subscriptions.length === 0) return 72 + 73 + const cancelled = { current: false } 74 + setLoading(true) 75 + const slice = subscriptions.slice(0, PAGE_SIZE) 76 + 77 + resolvePubs(buildByAuthor(slice), cancelled) 78 + .then((newPubs) => { 79 + if (cancelled.current) return 80 + setPubs(newPubs) 81 + setOffset(slice.length) 82 + setHasMore(slice.length < subscriptions.length) 83 + }) 84 + .catch((err) => { if (!cancelled.current) setError(err instanceof Error ? err.message : 'Failed to load') }) 85 + .finally(() => { if (!cancelled.current) setLoading(false) }) 30 86 31 - if (subscriptions.length === 0) { 32 - setPubs([]) 33 - return 34 - } 87 + return () => { cancelled.current = true } 88 + }, [subscriptions]) 35 89 36 - let cancelled = false 37 - setLoading(true) 90 + // Subsequent pages — IntersectionObserver on the sentinel 91 + useEffect(() => { 92 + if (!hasMore || loading || !sentinelRef.current || !subscriptions) return 93 + const sentinel = sentinelRef.current 94 + // Capture current values so the callback sees the right slice 95 + const currentOffset = offset 96 + const currentSubs = subscriptions 38 97 39 - const byAuthor = new Map<string, { rkey: string; publicationAtUri: string }[]>() 40 - for (const s of subscriptions) { 41 - const list = byAuthor.get(s.authorDid) ?? [] 42 - list.push({ rkey: s.rkey, publicationAtUri: s.publicationAtUri }) 43 - byAuthor.set(s.authorDid, list) 44 - } 98 + const observer = new IntersectionObserver((entries) => { 99 + if (!entries[0].isIntersecting) return 100 + observer.disconnect() 45 101 46 - const authors = [...byAuthor.entries()] 47 - const BATCH = 5 48 - const allResults: SubscribedPub[] = [] 102 + const slice = currentSubs.slice(currentOffset, currentOffset + PAGE_SIZE) 103 + if (slice.length === 0) { setHasMore(false); return } 49 104 50 - async function fetchBatched() { 51 - for (let i = 0; i < authors.length; i += BATCH) { 52 - if (cancelled) return 53 - const batch = authors.slice(i, i + BATCH) 54 - const results = await Promise.allSettled( 55 - batch.map(async ([did, entries]) => { 56 - const pubList = await listPublicPublications(did) 57 - return entries.flatMap(({ rkey, publicationAtUri }) => { 58 - const pub = pubList.find((p) => p.atUri === publicationAtUri) 59 - return pub ? [{ ...pub, rkey }] : [] 60 - }) 61 - }), 62 - ) 63 - for (const r of results) { 64 - if (r.status === 'fulfilled') allResults.push(...r.value) 65 - } 66 - } 67 - if (!cancelled) setPubs(allResults) 68 - } 105 + const cancelled = { current: false } 106 + setLoading(true) 69 107 70 - fetchBatched() 71 - .catch((err) => { if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load') }) 72 - .finally(() => { if (!cancelled) setLoading(false) }) 108 + resolvePubs(buildByAuthor(slice), cancelled) 109 + .then((newPubs) => { 110 + if (cancelled.current) return 111 + setPubs((prev) => [...prev, ...newPubs]) 112 + const nextOffset = currentOffset + slice.length 113 + setOffset(nextOffset) 114 + setHasMore(nextOffset < currentSubs.length) 115 + }) 116 + .catch((err) => { if (!cancelled.current) setError(err instanceof Error ? err.message : 'Failed to load more') }) 117 + .finally(() => { if (!cancelled.current) setLoading(false) }) 118 + }) 73 119 74 - return () => { cancelled = true } 75 - }, [subscriptions]) 120 + observer.observe(sentinel) 121 + return () => observer.disconnect() 122 + }, [hasMore, loading, offset, subscriptions]) 76 123 77 124 async function handleUnsubscribe(pub: SubscribedPub) { 78 125 if (!session) return ··· 80 127 try { 81 128 await unsubscribe(session, pub.rkey) 82 129 removeSubscription(pub.rkey) 83 - setPubs((prev) => prev?.filter((p) => p.atUri !== pub.atUri) ?? prev) 130 + setPubs((prev) => prev.filter((p) => p.atUri !== pub.atUri)) 84 131 } catch { 85 132 // non-fatal 86 133 } finally { ··· 109 156 110 157 {error && <p className="text-sm text-red-500">{error}</p>} 111 158 112 - {loading && ( 159 + {loading && pubs.length === 0 && ( 113 160 <div className="flex justify-center py-12"> 114 161 <Spinner /> 115 162 </div> 116 163 )} 117 164 118 - {!loading && pubs !== null && pubs.length === 0 && ( 165 + {subscriptions !== undefined && subscriptions.length === 0 && ( 119 166 <EmptyState 120 167 message="No subscriptions yet." 121 168 detail="Visit an author's profile and subscribe to their publication." 122 169 /> 123 170 )} 124 171 125 - {pubs !== null && pubs.length > 0 && ( 172 + {pubs.length > 0 && ( 126 173 <ul className="space-y-2"> 127 174 {pubs.map((pub) => { 128 175 const isLoading = !!loadingPubs[pub.atUri] ··· 149 196 ) 150 197 })} 151 198 </ul> 199 + )} 200 + 201 + <div ref={sentinelRef} /> 202 + 203 + {loading && pubs.length > 0 && ( 204 + <div className="flex justify-center py-4"> 205 + <Spinner size="md" /> 206 + </div> 207 + )} 208 + 209 + {!hasMore && !loading && pubs.length > 0 && ( 210 + <p className="text-center text-sm text-stone-400 dark:text-stone-600 py-4"> 211 + You've reached the end. 212 + </p> 152 213 )} 153 214 </PageLayout> 154 215 )