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 type { Post } from "app/(home-pages)/reader/getReaderFeed";
12
13import Link from "next/link";
14import { InteractionPreview, TagPopover } from "./InteractionsPreview";
15import { useLocalizedDate } from "src/hooks/useLocalizedDate";
16import { useSmoker } from "./Toast";
17import { Separator } from "./Layout";
18import { CommentTiny } from "./Icons/CommentTiny";
19import { QuoteTiny } from "./Icons/QuoteTiny";
20import { ShareTiny } from "./Icons/ShareTiny";
21import { useSelectedPostListing } from "src/useSelectedPostState";
22import { mergePreferences } from "src/utils/mergePreferences";
23
24export const PostListing = (props: Post) => {
25 let pubRecord = props.publication?.pubRecord as
26 | NormalizedPublication
27 | undefined;
28
29 let postRecord = props.documents.data as NormalizedDocument | null;
30
31 // Don't render anything for records that can't be normalized (e.g., site.standard records without expected fields)
32 if (!postRecord) {
33 return null;
34 }
35 let postUri = new AtUri(props.documents.uri);
36 let uri = props.publication ? props.publication?.uri : props.documents.uri;
37
38 // For standalone documents (no publication), pass isStandalone to get correct defaults
39 let isStandalone = !pubRecord;
40 let theme = usePubTheme(pubRecord?.theme || postRecord?.theme, isStandalone);
41 let themeRecord = pubRecord?.theme || postRecord?.theme;
42 let el = document?.getElementById(`post-listing-${postUri}`);
43
44 let hasBackgroundImage =
45 !!themeRecord?.backgroundImage?.image &&
46 el &&
47 Number(window.getComputedStyle(el).getPropertyValue("--bg-page-alpha")) <
48 0.7;
49
50 let backgroundImage =
51 themeRecord?.backgroundImage?.image?.ref && uri
52 ? blobRefToSrc(themeRecord.backgroundImage.image.ref, new AtUri(uri).host)
53 : null;
54
55 let backgroundImageRepeat = themeRecord?.backgroundImage?.repeat;
56 let backgroundImageSize = themeRecord?.backgroundImage?.width || 500;
57
58 let showPageBackground = pubRecord
59 ? pubRecord?.theme?.showPageBackground
60 : postRecord.theme?.showPageBackground ?? true;
61
62 let mergedPrefs = mergePreferences(
63 postRecord?.preferences,
64 pubRecord?.preferences,
65 );
66
67 let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0;
68 let comments =
69 mergedPrefs.showComments === false
70 ? 0
71 : props.documents.comments_on_documents?.[0]?.count || 0;
72 let recommends = props.documents.recommends_on_documents?.[0]?.count || 0;
73 let tags = (postRecord?.tags as string[] | undefined) || [];
74
75 // For standalone posts, link directly to the document
76 let postUrl = props.publication
77 ? `${props.publication.href}/${postUri.rkey}`
78 : `/p/${postUri.host}/${postUri.rkey}`;
79
80 return (
81 <div className="postListing flex flex-col gap-1">
82 <BaseThemeProvider {...theme} local>
83 <div
84 id={`post-listing-${postUri}`}
85 className={`
86 relative
87 flex flex-col overflow-hidden
88 selected-outline border-border-light rounded-lg w-full hover:outline-accent-contrast
89 hover:border-accent-contrast
90 ${showPageBackground ? "bg-bg-page " : "bg-bg-leaflet"} `}
91 style={
92 hasBackgroundImage
93 ? {
94 backgroundImage: backgroundImage
95 ? `url(${backgroundImage})`
96 : undefined,
97 backgroundRepeat: backgroundImageRepeat
98 ? "repeat"
99 : "no-repeat",
100 backgroundSize: backgroundImageRepeat
101 ? `${backgroundImageSize}px`
102 : "cover",
103 }
104 : {}
105 }
106 >
107 <Link
108 className="h-full w-full absolute top-0 left-0"
109 href={postUrl}
110 />
111 {postRecord.coverImage && (
112 <div className="postListingImage">
113 <img
114 src={blobRefToSrc(postRecord.coverImage.ref, postUri.host)}
115 alt={postRecord.title || ""}
116 className="w-full h-auto aspect-video rounded"
117 />
118 </div>
119 )}
120 <div className="postListingInfo px-3 py-2">
121 <h3 className="postListingTitle text-primary line-clamp-2 sm:text-lg text-base">
122 {postRecord.title}
123 </h3>
124
125 <p className="postListingDescription text-secondary line-clamp-3 sm:text-base text-sm">
126 {postRecord.description}
127 </p>
128 <div className="flex flex-col-reverse md:flex-row md gap-2 text-sm text-tertiary items-center justify-start pt-1.5 md:pt-3 w-full">
129 {props.publication && pubRecord && (
130 <PubInfo
131 href={props.publication.href}
132 pubRecord={pubRecord}
133 uri={props.publication.uri}
134 />
135 )}
136 <div className="flex flex-row justify-between gap-2 text-xs items-center w-full">
137 <PostDate publishedAt={postRecord.publishedAt} />
138 {tags.length === 0 ? null : <TagPopover tags={tags!} />}
139 </div>
140 </div>
141 </div>
142 </div>
143 </BaseThemeProvider>
144 <div className="text-sm flex justify-between text-tertiary">
145 <Interactions
146 postUrl={postUrl}
147 quotesCount={quotes}
148 commentsCount={comments}
149 tags={tags}
150 showComments={mergedPrefs.showComments !== false}
151 showMentions={mergedPrefs.showMentions !== false}
152 documentUri={props.documents.uri}
153 document={postRecord}
154 />
155 <Share postUrl={postUrl} />
156 </div>
157 </div>
158 );
159};
160
161const PubInfo = (props: {
162 href: string;
163 pubRecord: NormalizedPublication;
164 uri: string;
165}) => {
166 return (
167 <div className="flex flex-col md:w-auto shrink-0 w-full">
168 <hr className="md:hidden block border-border-light mb-1" />
169 <Link
170 href={props.href}
171 className="text-accent-contrast font-bold no-underline text-sm flex gap-[6px] items-center md:w-fit relative shrink-0"
172 >
173 <PubIcon tiny record={props.pubRecord} uri={props.uri} />
174 {props.pubRecord.name}
175 </Link>
176 </div>
177 );
178};
179
180const PostDate = (props: { publishedAt: string | undefined }) => {
181 let localizedDate = useLocalizedDate(props.publishedAt || "", {
182 year: "numeric",
183 month: "short",
184 day: "numeric",
185 });
186 if (props.publishedAt) {
187 return <div className="shrink-0 sm:text-sm text-xs">{localizedDate}</div>;
188 } else return null;
189};
190
191const Interactions = (props: {
192 quotesCount: number;
193 commentsCount: number;
194 tags?: string[];
195 postUrl: string;
196 showComments: boolean;
197 showMentions: boolean;
198 documentUri: string;
199 document: NormalizedDocument;
200}) => {
201 let setSelectedPostListing = useSelectedPostListing(
202 (s) => s.setSelectedPostListing,
203 );
204 let selectPostListing = (drawer: "quotes" | "comments") => {
205 setSelectedPostListing({
206 document_uri: props.documentUri,
207 document: props.document,
208 drawer,
209 });
210 };
211
212 return (
213 <div
214 className={`flex gap-2 text-tertiary text-sm items-center justify-between px-1`}
215 >
216 <div className="postListingsInteractions flex gap-3">
217 {!props.showMentions || props.quotesCount === 0 ? null : (
218 <button
219 aria-label="Post quotes"
220 onClick={() => selectPostListing("quotes")}
221 className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast text-tertiary"
222 >
223 <QuoteTiny /> {props.quotesCount}
224 </button>
225 )}
226 {!props.showComments || props.commentsCount === 0 ? null : (
227 <button
228 aria-label="Post comments"
229 onClick={() => selectPostListing("comments")}
230 className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast text-tertiary"
231 >
232 <CommentTiny /> {props.commentsCount}
233 </button>
234 )}
235 </div>
236 </div>
237 );
238};
239
240const Share = (props: { postUrl: string }) => {
241 let smoker = useSmoker();
242 return (
243 <button
244 id={`copy-post-link-${props.postUrl}`}
245 className="flex gap-1 items-center hover:text-accent-contrast relative font-bold"
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 <ShareTiny />
265 </button>
266 );
267};