an appview-less Bluesky client using Constellation and PDS Queries
reddwarf.app
frontend
spa
bluesky
reddwarf
microcosm
1import { useQueryClient } from "@tanstack/react-query";
2import * as React from "react";
3
4//import { useInView } from "react-intersection-observer";
5import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer";
6import { useAuth } from "~/providers/UnifiedAuthProvider";
7import {
8 useInfiniteQueryFeedSkeleton,
9 // useQueryArbitrary,
10 // useQueryIdentity,
11} from "~/utils/useQuery";
12
13interface InfiniteCustomFeedProps {
14 feedUri: string;
15 pdsUrl?: string;
16 feedServiceDid?: string;
17 authedOverride?: boolean;
18 unauthedfeedurl?: string;
19}
20
21export function InfiniteCustomFeed({
22 feedUri,
23 pdsUrl,
24 feedServiceDid,
25 authedOverride,
26 unauthedfeedurl,
27}: InfiniteCustomFeedProps) {
28 const { agent } = useAuth();
29 const authed = authedOverride || !!agent?.did;
30
31 // const identityresultmaybe = useQueryIdentity(agent?.did);
32 // const identity = identityresultmaybe?.data;
33 // const feedGenGetRecordQuery = useQueryArbitrary(feedUri);
34
35 const {
36 data,
37 error,
38 isLoading,
39 isError,
40 hasNextPage,
41 fetchNextPage,
42 isFetchingNextPage,
43 refetch,
44 isRefetching,
45 queryKey,
46 } = useInfiniteQueryFeedSkeleton({
47 feedUri: feedUri,
48 agent: agent ?? undefined,
49 isAuthed: authed ?? false,
50 pdsUrl: pdsUrl,
51 feedServiceDid: feedServiceDid,
52 unauthedfeedurl: unauthedfeedurl,
53 });
54 const queryClient = useQueryClient();
55
56
57 const handleRefresh = () => {
58 queryClient.removeQueries({queryKey: queryKey});
59 //queryClient.invalidateQueries(["infinite-feed", feedUri] as const);
60 refetch();
61 };
62
63 const allPosts = React.useMemo(() => {
64 const flattenedPosts = data?.pages.flatMap((page) => page?.feed) ?? [];
65
66 const seenUris = new Set<string>();
67
68 return flattenedPosts.filter((item) => {
69 if (!item?.post) return false;
70
71 if (seenUris.has(item.post)) {
72 return false;
73 }
74
75 seenUris.add(item.post);
76
77 return true;
78 });
79 }, [data]);
80
81 //const { ref, inView } = useInView();
82
83 // React.useEffect(() => {
84 // if (inView && hasNextPage && !isFetchingNextPage) {
85 // fetchNextPage();
86 // }
87 // }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
88
89 if (isLoading) {
90 return <div className="p-4 text-center text-gray-500">Loading feed...</div>;
91 }
92
93 if (isError) {
94 return (
95 <div className="p-4 text-center text-red-500">Error: {error.message}</div>
96 );
97 }
98
99 // const allPosts =
100 // data?.pages.flatMap((page) => {
101 // if (page) return page.feed;
102 // }) ?? [];
103
104 if (!allPosts || typeof allPosts !== "object" || allPosts.length === 0) {
105 return (
106 <div className="p-4 text-center text-gray-500">
107 No posts in this feed.
108 </div>
109 );
110 }
111
112 return (
113 <>
114 {allPosts.map((item, i) => {
115 if (item)
116 return (
117 <UniversalPostRendererATURILoader
118 key={item.post || i}
119 atUri={item.post}
120 feedviewpost={true}
121 repostedby={!!item.reason?.$type && (item.reason as any)?.repost}
122 />
123 );
124 })}
125 {/* allPosts?: {allPosts ? "true" : "false"}
126 hasNextPage?: {hasNextPage ? "true" : "false"}
127 isFetchingNextPage?: {isFetchingNextPage ? "true" : "false"} */}
128 {isFetchingNextPage && (
129 <div className="p-4 text-center text-gray-500">Loading more...</div>
130 )}
131 {hasNextPage && !isFetchingNextPage && (
132 <button
133 onClick={() => fetchNextPage()}
134 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"
135 >
136 Load More Posts
137 </button>
138 )}
139 {!hasNextPage && (
140 <div className="p-4 text-center text-gray-500">End of feed.</div>
141 )}
142 <button
143 onClick={handleRefresh}
144 disabled={isRefetching}
145 className="sticky lg:bottom-4 bottom-22 ml-4 w-[42px] h-[42px] z-10 bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 text-gray-50 p-[9px] rounded-full shadow-lg transition-transform duration-200 ease-in-out hover:scale-110 disabled:dark:bg-gray-900 disabled:bg-gray-100 disabled:cursor-not-allowed"
146 aria-label="Refresh feed"
147 >
148 <RefreshIcon
149 className={`h-6 w-6 text-gray-600 dark:text-gray-400 ${isRefetching && "animate-spin"}`}
150 />
151 </button>
152 </>
153 );
154}
155
156const RefreshIcon = (props: React.SVGProps<SVGSVGElement>) => (
157 <svg
158 xmlns="http://www.w3.org/2000/svg"
159 //width={360}
160 //height={360}
161 viewBox="0 0 24 24"
162 {...props}
163 >
164 <path
165 fill="none"
166 stroke="currentColor"
167 strokeLinecap="round"
168 strokeLinejoin="round"
169 strokeWidth={2}
170 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"
171 ></path>
172 </svg>
173);