a tool for shared writing and social publishing
1"use client";
2import { AtUri } from "@atproto/api";
3import { PubIcon } from "components/ActionBar/Publications";
4import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider";
5import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider";
6import { blobRefToSrc } from "src/utils/blobRefToSrc";
7import type {
8 NormalizedDocument,
9 NormalizedPublication,
10} from "src/utils/normalizeRecords";
11import { hasLeafletContent } from "lexicons/src/normalize";
12import type { Post } from "app/(home-pages)/reader/getReaderFeed";
13
14import Link from "next/link";
15import { useEffect, useRef, useState } from "react";
16import { TagPopover } from "./InteractionsPreview";
17import { useLocalizedDate } from "src/hooks/useLocalizedDate";
18import { useSmoker } from "./Toast";
19import { CommentTiny } from "./Icons/CommentTiny";
20import { QuoteTiny } from "./Icons/QuoteTiny";
21import { ShareTiny } from "./Icons/ShareTiny";
22import { useSelectedPostListing } from "src/useSelectedPostState";
23import { mergePreferences } from "src/utils/mergePreferences";
24import { ExternalLinkTiny } from "./Icons/ExternalLinkTiny";
25import { getDocumentURL } from "app/lish/createPub/getPublicationURL";
26import { RecommendButton } from "./RecommendButton";
27import { getFirstParagraph } from "src/utils/getFirstParagraph";
28
29export const PostListing = (props: Post & { selected?: boolean }) => {
30 let pubRecord = props.publication?.pubRecord as
31 | NormalizedPublication
32 | undefined;
33
34 let postRecord = props.documents.data as NormalizedDocument | null;
35
36 // Don't render anything for records that can't be normalized (e.g., site.standard records without expected fields)
37 if (!postRecord) {
38 return null;
39 }
40 let postUri = new AtUri(props.documents.uri);
41 let uri = props.publication ? props.publication?.uri : props.documents.uri;
42
43 // For standalone documents (no publication), pass isStandalone to get correct defaults
44 let isStandalone = !pubRecord;
45 let theme = usePubTheme(pubRecord?.theme || postRecord?.theme, isStandalone);
46 let themeRecord = pubRecord?.theme || postRecord?.theme;
47 let elRef = useRef<HTMLDivElement>(null);
48 let [hasBackgroundImage, setHasBackgroundImage] = useState(false);
49
50 useEffect(() => {
51 if (!themeRecord?.backgroundImage?.image || !elRef.current) {
52 setHasBackgroundImage(false);
53 return;
54 }
55 let alpha = Number(
56 window
57 .getComputedStyle(elRef.current)
58 .getPropertyValue("--bg-page-alpha"),
59 );
60 setHasBackgroundImage(alpha < 0.7);
61 }, [themeRecord?.backgroundImage?.image]);
62
63 let backgroundImage =
64 themeRecord?.backgroundImage?.image?.ref && uri
65 ? blobRefToSrc(themeRecord.backgroundImage.image.ref, new AtUri(uri).host)
66 : null;
67
68 let backgroundImageRepeat = themeRecord?.backgroundImage?.repeat;
69 let backgroundImageSize = themeRecord?.backgroundImage?.width || 500;
70
71 let showPageBackground = pubRecord
72 ? pubRecord?.theme?.showPageBackground
73 : postRecord.theme?.showPageBackground ?? true;
74
75 let mergedPrefs = mergePreferences(
76 postRecord?.preferences,
77 pubRecord?.preferences,
78 );
79
80 let quotes =
81 props.documents.mentionsCount ??
82 props.documents.document_mentions_in_bsky?.[0]?.count ??
83 0;
84 let comments =
85 mergedPrefs.showComments === false
86 ? 0
87 : props.documents.comments_on_documents?.[0]?.count || 0;
88 let recommends = props.documents.recommends_on_documents?.[0]?.count || 0;
89 let tags = (postRecord?.tags as string[] | undefined) || [];
90
91 // For standalone posts, link directly to the document
92 let postUrl = getDocumentURL(postRecord, props.documents.uri, pubRecord);
93
94 return (
95 <div className="postListing flex flex-col gap-1">
96 <BaseThemeProvider {...theme} local>
97 <div
98 ref={elRef}
99 id={`post-listing-${postUri}`}
100 className={`
101 relative
102 flex flex-col overflow-hidden
103 selected-outline rounded-lg w-full
104 ${props.selected ? "outline-2 outline-offset-1 outline-accent-contrast border-accent-contrast" : "hover:outline-accent-contrast hover:border-accent-contrast border-border-light"}
105 ${showPageBackground ? "bg-bg-page " : "bg-bg-leaflet"} `}
106 style={
107 hasBackgroundImage
108 ? {
109 backgroundImage: backgroundImage
110 ? `url(${backgroundImage})`
111 : undefined,
112 backgroundRepeat: backgroundImageRepeat
113 ? "repeat"
114 : "no-repeat",
115 backgroundSize: backgroundImageRepeat
116 ? `${backgroundImageSize}px`
117 : "cover",
118 }
119 : {}
120 }
121 >
122 <Link
123 className="h-full w-full absolute top-0 left-0"
124 href={postUrl}
125 />
126 {postRecord.coverImage && (
127 <div className="postListingImage">
128 <img
129 src={blobRefToSrc(postRecord.coverImage.ref, postUri.host)}
130 alt={postRecord.title || ""}
131 className="w-full h-auto aspect-video object-cover object-top-left rounded"
132 />
133 </div>
134 )}
135 <div className="postListingInfo px-3 py-2">
136 {postRecord.title && (
137 <h3 className="postListingTitle text-primary line-clamp-2 sm:text-lg text-base pb-0.5">
138 {postRecord.title}
139 </h3>
140 )}
141
142 <p className="postListingDescription text-secondary line-clamp-3 leading-snug sm:text-base text-sm">
143 {postRecord.description || getFirstParagraph(postRecord)}
144 </p>
145 <div className="flex flex-col-reverse gap-2 text-sm text-tertiary items-center justify-start pt-1.5 w-full">
146 {props.publication && pubRecord && (
147 <PubInfo
148 href={props.publication.href}
149 pubRecord={pubRecord}
150 uri={props.publication.uri}
151 postRecord={postRecord}
152 />
153 )}
154 <div className="flex flex-row justify-between gap-2 text-xs items-center w-full">
155 <PostDate publishedAt={postRecord.publishedAt} />
156 {tags.length === 0 ? null : <TagPopover tags={tags!} />}
157 </div>
158 </div>
159 </div>
160 </div>
161 </BaseThemeProvider>
162 <div className="text-sm flex justify-between text-tertiary">
163 <Interactions
164 postUrl={postUrl}
165 quotesCount={quotes}
166 commentsCount={comments}
167 recommendsCount={recommends}
168 tags={tags}
169 showComments={mergedPrefs.showComments !== false}
170 showMentions={mergedPrefs.showMentions !== false}
171 documentUri={props.documents.uri}
172 document={postRecord}
173 publication={pubRecord}
174 />
175 <Share postUrl={postUrl} />
176 </div>
177 </div>
178 );
179};
180
181const PubInfo = (props: {
182 href: string;
183 pubRecord: NormalizedPublication;
184 uri: string;
185 postRecord: NormalizedDocument;
186}) => {
187 let isLeaflet = hasLeafletContent(props.postRecord);
188 let cleanUrl = props.pubRecord.url
189 ?.replace(/^https?:\/\//, "")
190 .replace(/^www\./, "");
191
192 return (
193 <div className="flex flex-col shrink-0 w-full">
194 <hr className=" block border-border-light mb-1" />
195 <div className="flex justify-between gap-4 w-full ">
196 <Link
197 href={props.href}
198 className="text-accent-contrast font-bold no-underline text-sm flex gap-[6px] items-center relative grow w-max shrink-0 min-w-0"
199 >
200 <PubIcon tiny record={props.pubRecord} uri={props.uri} />
201 <div className="w-max min-w-0">{props.pubRecord.name}</div>
202 </Link>
203 {!isLeaflet && (
204 <div className="text-sm flex flex-row items-center text-tertiary gap-1 min-w-0">
205 <div className="truncate min-w-0">{cleanUrl}</div>
206 <ExternalLinkTiny className="shrink-0" />
207 </div>
208 )}
209 </div>
210 </div>
211 );
212};
213
214const PostDate = (props: { publishedAt: string | undefined }) => {
215 let localizedDate = useLocalizedDate(props.publishedAt || "", {
216 year: "numeric",
217 month: "short",
218 day: "numeric",
219 });
220 if (props.publishedAt) {
221 return <div className="shrink-0 sm:text-sm text-xs">{localizedDate}</div>;
222 } else return null;
223};
224
225const Interactions = (props: {
226 quotesCount: number;
227 commentsCount: number;
228 recommendsCount: number;
229 tags?: string[];
230 postUrl: string;
231 showComments: boolean;
232 showMentions: boolean;
233 documentUri: string;
234 document: NormalizedDocument;
235 publication?: NormalizedPublication;
236}) => {
237 let setSelectedPostListing = useSelectedPostListing(
238 (s) => s.setSelectedPostListing,
239 );
240 let selectPostListing = (drawer: "quotes" | "comments") => {
241 setSelectedPostListing({
242 document_uri: props.documentUri,
243 document: props.document,
244 publication: props.publication,
245 drawer,
246 });
247 };
248
249 return (
250 <div
251 className={`flex gap-2 text-tertiary text-sm items-center justify-between px-1`}
252 >
253 <div className="postListingsInteractions flex gap-3">
254 <RecommendButton
255 documentUri={props.documentUri}
256 recommendsCount={props.recommendsCount}
257 />
258 {!props.showMentions || props.quotesCount === 0 ? null : (
259 <button
260 aria-label="Post quotes"
261 onClick={() => selectPostListing("quotes")}
262 className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast text-tertiary"
263 >
264 <QuoteTiny /> {props.quotesCount}
265 </button>
266 )}
267 {!props.showComments || props.commentsCount === 0 ? null : (
268 <button
269 aria-label="Post comments"
270 onClick={() => selectPostListing("comments")}
271 className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast text-tertiary"
272 >
273 <CommentTiny /> {props.commentsCount}
274 </button>
275 )}
276 </div>
277 </div>
278 );
279};
280
281const Share = (props: { postUrl: string }) => {
282 let smoker = useSmoker();
283 return (
284 <button
285 id={`copy-post-link-${props.postUrl}`}
286 className="flex gap-1 items-center hover:text-accent-contrast relative font-bold"
287 onClick={(e) => {
288 e.stopPropagation();
289 e.preventDefault();
290 let mouseX = e.clientX;
291 let mouseY = e.clientY;
292
293 if (!props.postUrl) return;
294 navigator.clipboard.writeText(
295 props.postUrl.includes("http")
296 ? props.postUrl
297 : `leaflet.pub/${props.postUrl}`,
298 );
299
300 smoker({
301 text: <strong>Copied Link!</strong>,
302 position: {
303 y: mouseY,
304 x: mouseX,
305 },
306 });
307 }}
308 >
309 Share <ShareTiny />
310 </button>
311 );
312};