forked from
leaflet.pub/leaflet
a tool for shared writing and social publishing
1"use client";
2import { AtUri } from "@atproto/api";
3import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
4import { PubIcon } from "components/ActionBar/Publications";
5import { ButtonPrimary } from "components/Buttons";
6import { CommentTiny } from "components/Icons/CommentTiny";
7import { DiscoverSmall } from "components/Icons/DiscoverSmall";
8import { QuoteTiny } from "components/Icons/QuoteTiny";
9import { Separator } from "components/Layout";
10import { SpeedyLink } from "components/SpeedyLink";
11import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider";
12import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider";
13import { useSmoker } from "components/Toast";
14import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api";
15import { blobRefToSrc } from "src/utils/blobRefToSrc";
16import { Json } from "supabase/database.types";
17import type { Cursor, Post } from "./getReaderFeed";
18import useSWRInfinite from "swr/infinite";
19import { getReaderFeed } from "./getReaderFeed";
20import { useEffect, useRef } from "react";
21import { useRouter } from "next/navigation";
22
23export const ReaderContent = (props: {
24 root_entity: string;
25 posts: Post[];
26 nextCursor: Cursor | null;
27}) => {
28 const getKey = (
29 pageIndex: number,
30 previousPageData: { posts: Post[]; nextCursor: Cursor | null } | null,
31 ) => {
32 // Reached the end
33 if (previousPageData && !previousPageData.nextCursor) return null;
34
35 // First page, we don't have previousPageData
36 if (pageIndex === 0) return ["reader-feed", null] as const;
37
38 // Add the cursor to the key
39 return ["reader-feed", previousPageData?.nextCursor] as const;
40 };
41
42 const { data, error, size, setSize, isValidating } = useSWRInfinite(
43 getKey,
44 ([_, cursor]) => getReaderFeed(cursor),
45 {
46 fallbackData: [{ posts: props.posts, nextCursor: props.nextCursor }],
47 revalidateFirstPage: false,
48 },
49 );
50
51 const loadMoreRef = useRef<HTMLDivElement>(null);
52
53 // Set up intersection observer to load more when trigger element is visible
54 useEffect(() => {
55 const observer = new IntersectionObserver(
56 (entries) => {
57 if (entries[0].isIntersecting && !isValidating) {
58 const hasMore = data && data[data.length - 1]?.nextCursor;
59 if (hasMore) {
60 setSize(size + 1);
61 }
62 }
63 },
64 { threshold: 0.1 },
65 );
66
67 if (loadMoreRef.current) {
68 observer.observe(loadMoreRef.current);
69 }
70
71 return () => observer.disconnect();
72 }, [data, size, setSize, isValidating]);
73
74 const allPosts = data ? data.flatMap((page) => page.posts) : [];
75
76 if (allPosts.length === 0 && !isValidating) return <ReaderEmpty />;
77
78 return (
79 <div className="flex flex-col gap-3 relative">
80 {allPosts.map((p) => (
81 <Post {...p} key={p.documents.uri} />
82 ))}
83 {/* Trigger element for loading more posts */}
84 <div
85 ref={loadMoreRef}
86 className="absolute bottom-96 left-0 w-full h-px pointer-events-none"
87 aria-hidden="true"
88 />
89 {isValidating && (
90 <div className="text-center text-tertiary py-4">
91 Loading more posts...
92 </div>
93 )}
94 </div>
95 );
96};
97
98const Post = (props: Post) => {
99 let pubRecord = props.publication.pubRecord as PubLeafletPublication.Record;
100
101 let postRecord = props.documents.data as PubLeafletDocument.Record;
102 let postUri = new AtUri(props.documents.uri);
103
104 let theme = usePubTheme(pubRecord);
105 let backgroundImage = pubRecord?.theme?.backgroundImage?.image?.ref
106 ? blobRefToSrc(
107 pubRecord?.theme?.backgroundImage?.image?.ref,
108 new AtUri(props.publication.uri).host,
109 )
110 : null;
111
112 let backgroundImageRepeat = pubRecord?.theme?.backgroundImage?.repeat;
113 let backgroundImageSize = pubRecord?.theme?.backgroundImage?.width || 500;
114
115 let showPageBackground = pubRecord.theme?.showPageBackground;
116
117 let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0;
118 let comments =
119 pubRecord.preferences?.showComments === false
120 ? 0
121 : props.documents.comments_on_documents?.[0]?.count || 0;
122
123 return (
124 <BaseThemeProvider {...theme} local>
125 <div
126 style={{
127 backgroundImage: `url(${backgroundImage})`,
128 backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat",
129 backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`,
130 }}
131 className={`no-underline! flex flex-row gap-2 w-full relative
132 bg-bg-leaflet
133 border border-border-light rounded-lg
134 sm:p-2 p-2 selected-outline
135 hover:outline-accent-contrast hover:border-accent-contrast
136 `}
137 >
138 <a
139 className="h-full w-full absolute top-0 left-0"
140 href={`${props.publication.href}/${postUri.rkey}`}
141 />
142 <div
143 className={`${showPageBackground ? "bg-bg-page " : "bg-transparent"} rounded-md w-full px-[10px] pt-2 pb-2`}
144 style={{
145 backgroundColor: showPageBackground
146 ? "rgba(var(--bg-page), var(--bg-page-alpha))"
147 : "transparent",
148 }}
149 >
150 <h3 className="text-primary truncate">{postRecord.title}</h3>
151
152 <p className="text-secondary">{postRecord.description}</p>
153 <div className="flex justify-between items-end">
154 <div className="flex flex-col-reverse md:flex-row md gap-3 md:gap-2 text-sm text-tertiary items-center justify-start pt-1 md:pt-3">
155 <PubInfo
156 href={props.publication.href}
157 pubRecord={pubRecord}
158 uri={props.publication.uri}
159 />
160 <Separator classname="h-4 !min-h-0 md:block hidden" />
161 <PostInfo
162 author={props.author || ""}
163 publishedAt={postRecord.publishedAt}
164 />
165 </div>
166
167 <PostInterations
168 postUrl={`${props.publication.href}/${postUri.rkey}`}
169 quotesCount={quotes}
170 commentsCount={comments}
171 showComments={pubRecord.preferences?.showComments}
172 />
173 </div>
174 </div>
175 </div>
176 </BaseThemeProvider>
177 );
178};
179
180const PubInfo = (props: {
181 href: string;
182 pubRecord: PubLeafletPublication.Record;
183 uri: string;
184}) => {
185 return (
186 <a
187 href={props.href}
188 className="text-accent-contrast font-bold no-underline text-sm flex gap-1 items-center md:w-fit w-full relative shrink-0"
189 >
190 <PubIcon small record={props.pubRecord} uri={props.uri} />
191 {props.pubRecord.name}
192 </a>
193 );
194};
195
196const PostInfo = (props: {
197 author: string;
198 publishedAt: string | undefined;
199}) => {
200 return (
201 <div className="flex gap-2 grow items-center shrink-0">
202 {props.author}
203 {props.publishedAt && (
204 <>
205 <Separator classname="h-4 !min-h-0" />
206 {new Date(props.publishedAt).toLocaleDateString("en-US", {
207 year: "numeric",
208 month: "short",
209 day: "numeric",
210 })}{" "}
211 </>
212 )}
213 </div>
214 );
215};
216
217const PostInterations = (props: {
218 quotesCount: number;
219 commentsCount: number;
220 postUrl: string;
221 showComments: boolean | undefined;
222}) => {
223 let smoker = useSmoker();
224 let interactionsAvailable =
225 props.quotesCount > 0 ||
226 (props.showComments !== false && props.commentsCount > 0);
227
228 return (
229 <div className={`flex gap-2 text-tertiary text-sm items-center`}>
230 {props.quotesCount === 0 ? null : (
231 <div className={`flex gap-1 items-center `}>
232 <span className="sr-only">Post quotes</span>
233 <QuoteTiny aria-hidden /> {props.quotesCount}
234 </div>
235 )}
236 {props.showComments === false || props.commentsCount === 0 ? null : (
237 <div className={`flex gap-1 items-center`}>
238 <span className="sr-only">Post comments</span>
239 <CommentTiny aria-hidden /> {props.commentsCount}
240 </div>
241 )}
242 {interactionsAvailable && <Separator classname="h-4 !min-h-0" />}
243 <button
244 id={`copy-post-link-${props.postUrl}`}
245 className="flex gap-1 items-center hover:font-bold relative"
246 onClick={(e) => {
247 e.stopPropagation();
248 e.preventDefault();
249 let mouseX = e.clientX;
250 let mouseY = e.clientY;
251
252 if (!props.postUrl) return;
253 navigator.clipboard.writeText(`leaflet.pub${props.postUrl}`);
254
255 smoker({
256 text: <strong>Copied Link!</strong>,
257 position: {
258 y: mouseY,
259 x: mouseX,
260 },
261 });
262 }}
263 >
264 Share
265 </button>
266 </div>
267 );
268};
269const ReaderEmpty = () => {
270 return (
271 <div className="flex flex-col gap-2 container bg-[rgba(var(--bg-page),.7)] sm:p-4 p-3 justify-between text-center font-bold text-tertiary">
272 Nothing to read yet… <br />
273 Subscribe to publications and find their posts here!
274 <ButtonPrimary className="mx-auto place-self-center">
275 <DiscoverSmall /> Discover Publications
276 </ButtonPrimary>
277 </div>
278 );
279};