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 { InteractionPreview, TagPopover } from "./InteractionsPreview";
17import { useLocalizedDate } from "src/hooks/useLocalizedDate";
18import { useSmoker } from "./Toast";
19import { Separator } from "./Layout";
20import { CommentTiny } from "./Icons/CommentTiny";
21import { QuoteTiny } from "./Icons/QuoteTiny";
22import { ShareTiny } from "./Icons/ShareTiny";
23import { useSelectedPostListing } from "src/useSelectedPostState";
24import { mergePreferences } from "src/utils/mergePreferences";
25import { ExternalLinkTiny } from "./Icons/ExternalLinkTiny";
26import { getDocumentURL } from "app/lish/createPub/getPublicationURL";
27import { RecommendButton } from "./RecommendButton";
28
29export const PostListing = (props: Post) => {
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 border-border-light rounded-lg w-full hover:outline-accent-contrast
104 hover:border-accent-contrast
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 <h3 className="postListingTitle text-primary line-clamp-2 sm:text-lg text-base">
137 {postRecord.title}
138 </h3>
139
140 <p className="postListingDescription text-secondary line-clamp-3 sm:text-base text-sm">
141 {postRecord.description}
142 </p>
143 <div className="flex flex-col-reverse gap-2 text-sm text-tertiary items-center justify-start pt-1.5 w-full">
144 {props.publication && pubRecord && (
145 <PubInfo
146 href={props.publication.href}
147 pubRecord={pubRecord}
148 uri={props.publication.uri}
149 postRecord={postRecord}
150 />
151 )}
152 <div className="flex flex-row justify-between gap-2 text-xs items-center w-full">
153 <PostDate publishedAt={postRecord.publishedAt} />
154 {tags.length === 0 ? null : <TagPopover tags={tags!} />}
155 </div>
156 </div>
157 </div>
158 </div>
159 </BaseThemeProvider>
160 <div className="text-sm flex justify-between text-tertiary">
161 <Interactions
162 postUrl={postUrl}
163 quotesCount={quotes}
164 commentsCount={comments}
165 recommendsCount={recommends}
166 tags={tags}
167 showComments={mergedPrefs.showComments !== false}
168 showMentions={mergedPrefs.showMentions !== false}
169 documentUri={props.documents.uri}
170 document={postRecord}
171 />
172 <Share postUrl={postUrl} />
173 </div>
174 </div>
175 );
176};
177
178const PubInfo = (props: {
179 href: string;
180 pubRecord: NormalizedPublication;
181 uri: string;
182 postRecord: NormalizedDocument;
183}) => {
184 let isLeaflet = hasLeafletContent(props.postRecord);
185 let cleanUrl = props.pubRecord.url
186 ?.replace(/^https?:\/\//, "")
187 .replace(/^www\./, "");
188
189 return (
190 <div className="flex flex-col shrink-0 w-full">
191 <hr className=" block border-border-light mb-1" />
192 <div className="flex justify-between gap-4 w-full ">
193 <Link
194 href={props.href}
195 className="text-accent-contrast font-bold no-underline text-sm flex gap-[6px] items-center relative grow w-max shrink-0 min-w-0"
196 >
197 <PubIcon tiny record={props.pubRecord} uri={props.uri} />
198 <div className="w-max min-w-0">{props.pubRecord.name}</div>
199 </Link>
200 {!isLeaflet && (
201 <div className="text-sm flex flex-row items-center text-tertiary gap-1 min-w-0">
202 <div className="truncate min-w-0">{cleanUrl}</div>
203 <ExternalLinkTiny className="shrink-0" />
204 </div>
205 )}
206 </div>
207 </div>
208 );
209};
210
211const PostDate = (props: { publishedAt: string | undefined }) => {
212 let localizedDate = useLocalizedDate(props.publishedAt || "", {
213 year: "numeric",
214 month: "short",
215 day: "numeric",
216 });
217 if (props.publishedAt) {
218 return <div className="shrink-0 sm:text-sm text-xs">{localizedDate}</div>;
219 } else return null;
220};
221
222const Interactions = (props: {
223 quotesCount: number;
224 commentsCount: number;
225 recommendsCount: number;
226 tags?: string[];
227 postUrl: string;
228 showComments: boolean;
229 showMentions: boolean;
230 documentUri: string;
231 document: NormalizedDocument;
232}) => {
233 let setSelectedPostListing = useSelectedPostListing(
234 (s) => s.setSelectedPostListing,
235 );
236 let selectPostListing = (drawer: "quotes" | "comments") => {
237 setSelectedPostListing({
238 document_uri: props.documentUri,
239 document: props.document,
240 drawer,
241 });
242 };
243
244 return (
245 <div
246 className={`flex gap-2 text-tertiary text-sm items-center justify-between px-1`}
247 >
248 <div className="postListingsInteractions flex gap-3">
249 <RecommendButton
250 documentUri={props.documentUri}
251 recommendsCount={props.recommendsCount}
252 />
253 {!props.showMentions || props.quotesCount === 0 ? null : (
254 <button
255 aria-label="Post quotes"
256 onClick={() => selectPostListing("quotes")}
257 className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast text-tertiary"
258 >
259 <QuoteTiny /> {props.quotesCount}
260 </button>
261 )}
262 {!props.showComments || props.commentsCount === 0 ? null : (
263 <button
264 aria-label="Post comments"
265 onClick={() => selectPostListing("comments")}
266 className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast text-tertiary"
267 >
268 <CommentTiny /> {props.commentsCount}
269 </button>
270 )}
271 </div>
272 </div>
273 );
274};
275
276const Share = (props: { postUrl: string }) => {
277 let smoker = useSmoker();
278 return (
279 <button
280 id={`copy-post-link-${props.postUrl}`}
281 className="flex gap-1 items-center hover:text-accent-contrast relative font-bold"
282 onClick={(e) => {
283 e.stopPropagation();
284 e.preventDefault();
285 let mouseX = e.clientX;
286 let mouseY = e.clientY;
287
288 if (!props.postUrl) return;
289 navigator.clipboard.writeText(
290 props.postUrl.includes("http")
291 ? props.postUrl
292 : `leaflet.pub/${props.postUrl}`,
293 );
294
295 smoker({
296 text: <strong>Copied Link!</strong>,
297 position: {
298 y: mouseY,
299 x: mouseX,
300 },
301 });
302 }}
303 >
304 Share <ShareTiny />
305 </button>
306 );
307};