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