an appview-less Bluesky client using Constellation and PDS Queries reddwarf.app
frontend spa bluesky reddwarf microcosm
1/* eslint-disable react-hooks/refs */ 2import { useWindowVirtualizer } from "@tanstack/react-virtual"; 3import { useAtom } from "jotai"; 4import * as React from "react"; 5import { useEffect, useLayoutEffect } from "react"; 6 7//import { useInView } from "react-intersection-observer"; 8import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer"; 9import { useAuth } from "~/providers/UnifiedAuthProvider"; 10import { feedHeightsAtom, feedScrollIndexAtom } from "~/utils/atoms"; 11import { useInfiniteQueryFeedSkeleton } from "~/utils/useQuery"; 12 13interface InfiniteCustomFeedProps { 14 feedUri: string; 15 pdsUrl?: string; 16 feedServiceDid?: string; 17 initialScrollIndex?: number; 18 //onVisibleIndexChange?: (index: number) => void; 19} 20 21export function InfiniteCustomFeed({ 22 feedUri, 23 pdsUrl, 24 feedServiceDid, 25 initialScrollIndex, 26 //onVisibleIndexChange, 27}: InfiniteCustomFeedProps) { 28 const OVERSCAN_COUNT = 10; 29 const ESTIMATE_HEIGHT = 150; 30 31 const { agent } = useAuth(); 32 const authed = !!agent?.did; 33 34 const listRef = React.useRef<HTMLDivElement | null>(null); 35 const [offsetTop, setOffsetTop] = React.useState(0); 36 const [scrollIndexes, setScrollIndexes] = useAtom(feedScrollIndexAtom); 37 //const initialScrollIndex = scrollIndexes[feedUri]; 38 39 // const identityresultmaybe = useQueryIdentity(agent?.did); 40 // const identity = identityresultmaybe?.data; 41 // const feedGenGetRecordQuery = useQueryArbitrary(feedUri); 42 43 const { 44 data, 45 error, 46 isLoading, 47 isError, 48 hasNextPage, 49 fetchNextPage, 50 isFetchingNextPage, 51 refetch, 52 isRefetching, 53 } = useInfiniteQueryFeedSkeleton({ 54 feedUri: feedUri, 55 agent: agent ?? undefined, 56 isAuthed: authed ?? false, 57 pdsUrl: pdsUrl, 58 feedServiceDid: feedServiceDid, 59 }); 60 61 const handleRefresh = () => { 62 refetch(); 63 }; 64 65 //const { ref, inView } = useInView(); 66 67 // React.useEffect(() => { 68 // if (inView && hasNextPage && !isFetchingNextPage) { 69 // fetchNextPage(); 70 // } 71 // }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); 72 73 const allPosts = React.useMemo(() => { 74 const flattenedPosts = data?.pages.flatMap((page) => page?.feed) ?? []; 75 76 const seenUris = new Set<string>(); 77 78 return flattenedPosts.filter((item) => { 79 if (!item?.post) return false; 80 81 if (seenUris.has(item.post)) { 82 return false; 83 } 84 85 seenUris.add(item.post); 86 return true; 87 }); 88 }, [data]); 89 90 const [feedHeights, setFeedHeights] = useAtom(feedHeightsAtom); 91 const currentFeedCache = feedHeights[feedUri] ?? {}; 92 93 const virtualizerRef = React.useRef<ReturnType< 94 typeof useWindowVirtualizer 95 > | null>(null); 96 97 const virtualizer = useWindowVirtualizer({ 98 count: allPosts.length, 99 // + 100 // (isFetchingNextPage ? 1 : 0) + 101 // (hasNextPage && !isFetchingNextPage ? 1 : 0) + 102 // (!hasNextPage ? 1 : 0) + 103 // 1, 104 estimateSize: (index) => { 105 const post = allPosts[index]; 106 if (!post) return ESTIMATE_HEIGHT; 107 108 if (currentFeedCache[post.post]) { 109 return currentFeedCache[post.post]; 110 } 111 112 return ESTIMATE_HEIGHT; 113 }, 114 // measureElement: measureElement, 115 overscan: OVERSCAN_COUNT, 116 scrollMargin: offsetTop, 117 }); 118 // React.useEffect(() => { 119 // virtualizer.measure(); 120 // }, [data]); 121 122 const measureElement = React.useCallback( 123 (node: HTMLElement | null) => { 124 if (!node) return; 125 126 virtualizer.measureElement(node); 127 128 const postUri = node.dataset.postUri; 129 const newHeight = node.offsetHeight; 130 131 if (postUri && newHeight > 0 && currentFeedCache[postUri] !== newHeight) { 132 setFeedHeights((prev) => ({ 133 ...prev, 134 [feedUri]: { 135 ...prev[feedUri], 136 [postUri]: newHeight, 137 }, 138 })); 139 } 140 }, 141 [virtualizer, setFeedHeights, feedUri, currentFeedCache] 142 ); 143 144 virtualizerRef.current = virtualizer; 145 146 useLayoutEffect(() => { 147 const update = () => { 148 if (listRef.current) { 149 setOffsetTop(listRef.current.offsetTop); 150 } 151 //if (virtualizerRef.current) { 152 // virtualizerRef.current.measure(); 153 // } 154 }; 155 156 update(); 157 158 let debounceTimeout: NodeJS.Timeout; 159 160 const debouncedUpdate = () => { 161 clearTimeout(debounceTimeout); 162 debounceTimeout = setTimeout(update, 100); 163 }; 164 165 window.addEventListener("resize", debouncedUpdate); 166 167 return () => { 168 window.removeEventListener("resize", debouncedUpdate); 169 clearTimeout(debounceTimeout); 170 }; 171 }, []); 172 173 const hasRestoredScroll = React.useRef(false); 174 useLayoutEffect(() => { 175 if ( 176 hasRestoredScroll.current || 177 !initialScrollIndex || 178 initialScrollIndex === 0 179 ) { 180 return; 181 } 182 183 if (initialScrollIndex < allPosts.length) { 184 console.log(`Restoring scroll to index: ${initialScrollIndex}`); 185 virtualizer.scrollToIndex(initialScrollIndex, { 186 align: "start", 187 behavior: "auto", 188 }); 189 hasRestoredScroll.current = true; 190 } 191 }, [initialScrollIndex, allPosts.length, virtualizer]); 192 193 // React.useEffect(() => { 194 // const handleScroll = () => { 195 // const topVisibleItem = virtualizer.getVirtualItems()[0]; 196 // if (topVisibleItem && onVisibleIndexChange) { 197 // onVisibleIndexChange(topVisibleItem.index); 198 // } 199 // }; 200 201 // window.addEventListener('scroll', handleScroll, { passive: true }); 202 // return () => window.removeEventListener('scroll', handleScroll); 203 // }, [virtualizer, onVisibleIndexChange]); 204 205 useEffect(() => { 206 return () => { 207 const topVisibleItem = virtualizer.getVirtualItems()[OVERSCAN_COUNT]; 208 209 if (topVisibleItem) { 210 console.log( 211 `Saving final scroll index ${topVisibleItem.index} for feed ${feedUri}` 212 ); 213 setScrollIndexes((prev) => ({ 214 ...prev, 215 [feedUri]: topVisibleItem.index, 216 })); 217 } 218 }; 219 }, [virtualizer, feedUri, setScrollIndexes]); 220 221 if (isLoading) { 222 return <div className="p-4 text-center text-gray-500">Loading feed...</div>; 223 } 224 225 if (isError) { 226 return ( 227 <div className="p-4 text-center text-red-500">Error: {error.message}</div> 228 ); 229 } 230 231 if (!allPosts || typeof allPosts !== "object" || allPosts.length === 0) { 232 return ( 233 <div className="p-4 text-center text-gray-500"> 234 No posts in this feed. 235 </div> 236 ); 237 } 238 239 //if (offsetTop === 0) { 240 // return <div ref={listRef}>Calculating...</div>; 241 //} 242 243 return ( 244 <> 245 <div ref={listRef}> 246 <div 247 style={{ 248 height: `${virtualizer.getTotalSize()}px`, 249 width: "100%", 250 position: "relative", 251 }} 252 > 253 {virtualizer.getVirtualItems().map((virtualItem) => { 254 const item = allPosts[virtualItem.index]; 255 const i = virtualItem.index; 256 if (item) 257 return ( 258 <UniversalPostRendererATURILoader 259 key={item.post || i} 260 atUri={item.post} 261 dataIndexPropPass={i} 262 feedviewpost={true} 263 ref={measureElement} 264 repostedby={ 265 !!item.reason?.$type && (item.reason as any)?.repost 266 } 267 style={{ 268 position: "absolute", 269 top: 0, 270 left: 0, 271 width: "100%", 272 //height: `${item.size}px`, 273 transform: `translateY(${virtualItem.start - offsetTop}px)`, 274 }} 275 /> 276 ); 277 })} 278 </div> 279 </div> 280 281 {/* allPosts?: {allPosts ? "true" : "false"} 282 hasNextPage?: {hasNextPage ? "true" : "false"} 283 isFetchingNextPage?: {isFetchingNextPage ? "true" : "false"} */} 284 {isFetchingNextPage && ( 285 <div className="p-4 text-center text-gray-500">Loading more...</div> 286 )} 287 {hasNextPage && !isFetchingNextPage && ( 288 <button 289 onClick={() => fetchNextPage()} 290 className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold" 291 > 292 Load More Posts 293 </button> 294 )} 295 {!hasNextPage && ( 296 <div className="p-4 text-center text-gray-500">End of feed.</div> 297 )} 298 <button 299 onClick={handleRefresh} 300 disabled={isRefetching} 301 className="sticky lg:bottom-6 bottom-24 ml-4 w-[42px] h-[42px] z-10 bg-gray-500 hover:bg-gray-600 text-gray-50 p-[9px] rounded-full shadow-lg transition-transform duration-200 ease-in-out hover:scale-110 disabled:bg-gray-400 disabled:cursor-not-allowed" 302 aria-label="Refresh feed" 303 > 304 {isRefetching ? ( 305 <RefreshIcon className="h-6 w-6 animate-spin" /> 306 ) : ( 307 <RefreshIcon className="h-6 w-6" /> 308 )} 309 </button> 310 </> 311 ); 312} 313 314const RefreshIcon = (props: React.SVGProps<SVGSVGElement>) => ( 315 <svg 316 xmlns="http://www.w3.org/2000/svg" 317 //width={360} 318 //height={360} 319 viewBox="0 0 24 24" 320 {...props} 321 > 322 <path 323 fill="none" 324 stroke="currentColor" 325 strokeLinecap="round" 326 strokeLinejoin="round" 327 strokeWidth={2} 328 d="M20 11A8.1 8.1 0 0 0 4.5 9M4 5v4h4m-4 4a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4" 329 ></path> 330 </svg> 331);