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