Leaflet Blog in Deno Fresh
1import { useEffect, useState } from "preact/hooks";
2import {
3 AppBskyFeedDefs,
4 type AppBskyFeedGetPostThread,
5} from "npm:@atproto/api";
6import { CommentOptions } from "../components/bsky-comments/types.tsx";
7import { PostSummary } from "../components/bsky-comments/PostSummary.tsx";
8import { Comment } from "../components/bsky-comments/Comment.tsx";
9
10const getAtUri = (uri: string): string => {
11 if (!uri.startsWith("at://") && uri.includes("bsky.app/profile/")) {
12 const match = uri.match(/profile\/([\w:.]+)\/post\/([\w]+)/);
13 if (match) {
14 const [, did, postId] = match;
15 return `at://${did}/app.bsky.feed.post/${postId}`;
16 }
17 }
18 return uri;
19};
20
21/**
22 * This component displays a comment section for a post.
23 * It fetches the comments for a post and displays them in a threaded format.
24 */
25export const CommentSection = ({
26 uri: propUri,
27 author,
28 onEmpty,
29 commentFilters,
30}: CommentOptions): any => {
31 const [uri, setUri] = useState<string | null>(null);
32 const [thread, setThread] = useState<AppBskyFeedDefs.ThreadViewPost | null>(
33 null,
34 );
35 const [error, setError] = useState<string | null>(null);
36 const [visibleCount, setVisibleCount] = useState(5);
37
38 const styles = `
39 .container {
40 max-width: 740px;
41 margin: 0 auto;
42 }
43
44 .statsBar {
45 display: flex;
46 align-items: center;
47 gap: 0.5rem;
48 }
49
50 .statsBar:hover {
51 text-decoration: underline;
52 }
53
54 .statItem {
55 display: flex;
56 align-items: center;
57 gap: 0.25rem;
58 white-space: nowrap;
59 }
60
61 .container a.link {
62 text-decoration: underline;
63 }
64
65 .container a.link:hover {
66 text-decoration: underline;
67 }
68
69 .icon {
70 width: 1.25rem;
71 height: 1.25rem;
72 }
73
74 .errorText, .loadingText {
75 text-align: center;
76 }
77
78 .commentsTitle {
79 margin-top: 1.5rem;
80 font-size: 1.25rem;
81 font-weight: bold;
82 }
83
84 .replyText {
85 margin-top: 0.5rem;
86 font-size: 0.875rem;
87 }
88
89 .divider {
90 margin-top: 0.5rem;
91 }
92
93 .commentsList {
94 margin-top: 0.5rem;
95 display: flex;
96 flex-direction: column;
97 gap: 0.5rem;
98 }
99
100 .container .showMoreButton {
101 margin-top: 0.5rem;
102 font-size: 0.875rem;
103 text-decoration: underline;
104 cursor: pointer;
105 }
106
107 .container .showMoreButton:hover {
108 text-decoration: underline;
109 }
110
111 .commentContainer {
112 margin: 1rem 0;
113 font-size: 0.875rem;
114 }
115
116 .commentContent {
117 display: flex;
118 max-width: 36rem;
119 flex-direction: column;
120 gap: 0.5rem;
121 }
122
123 .authorLink {
124 display: flex;
125 flex-direction: row;
126 justify-content: flex-start;
127 align-items: center;
128 gap: 0.5rem;
129 }
130
131 .authorLink:hover {
132 text-decoration: underline;
133 }
134
135 .avatar {
136 height: 1rem;
137 width: 1rem;
138 flex-shrink: 0;
139 border-radius: 9999px;
140 background-color: #d1d5db;
141 }
142
143 .authorName {
144 overflow: hidden;
145 text-overflow: ellipsis;
146 white-space: nowrap;
147 display: -webkit-box;
148 -webkit-line-clamp: 1;
149 -webkit-box-orient: vertical;
150 }
151
152 .container a {
153 text-decoration: none;
154 color: inherit;
155 }
156
157 .container a:hover {
158 text-decoration: none;
159 }
160
161 .commentContent .handle {
162 color: #6b7280;
163 }
164 .repliesContainer {
165 border-left: 2px solid #525252;
166 padding-left: 0.5rem;
167 }
168
169 .actionsContainer {
170 margin-top: 0.5rem;
171 display: flex;
172 width: 100%;
173 max-width: 150px;
174 flex-direction: row;
175 align-items: center;
176 justify-content: space-between;
177 opacity: 0.6;
178 }
179
180
181 .actionsRow {
182 display: flex;
183 align-items: center;
184 gap: 0.25rem;
185 }
186
187
188 .font-sans { font-family: var(--font-sans); }
189 .font-serif { font-family: var(--font-serif); }
190 .font-mono { font-family: var(--font-mono); }
191
192 h1 {
193 font-family: var(--font-serif);
194 text-transform: uppercase;
195 font-size: 2.25rem;
196 }
197
198 h2 {
199 font-family: var(--font-serif);
200 text-transform: uppercase;
201 font-size: 1.75rem;
202 }
203
204 h3 {
205 font-family: var(--font-serif);
206 text-transform: uppercase;
207 font-size: 1.5rem;
208 }
209
210 h4 {
211 font-family: var(--font-serif);
212 text-transform: uppercase;
213 font-size: 1.25rem;
214 }
215
216 h5 {
217 font-family: var(--font-serif);
218 text-transform: uppercase;
219 font-size: 1rem;
220 }
221
222 h6 {
223 font-family: var(--font-serif);
224 text-transform: uppercase;
225 font-size: 0.875rem;
226 }
227`;
228
229 useEffect(() => {
230 let isSubscribed = true;
231
232 const initializeUri = async () => {
233 if (propUri) {
234 setUri(propUri);
235 return;
236 }
237
238 if (author) {
239 try {
240 const currentUrl = window.location.href;
241 const apiUrl =
242 `https://public.api.bsky.app/xrpc/app.bsky.feed.searchPosts?q=*&url=${
243 encodeURIComponent(
244 currentUrl,
245 )
246 }&author=${author}&sort=top`;
247
248 const response = await fetch(apiUrl);
249 const data = await response.json();
250
251 if (isSubscribed) {
252 if (data.posts && data.posts.length > 0) {
253 const post = data.posts[0];
254 setUri(post.uri);
255 } else {
256 setError("No matching post found");
257 if (onEmpty) {
258 onEmpty({
259 code: "not_found",
260 message: "No matching post found",
261 });
262 }
263 }
264 }
265 } catch (err) {
266 if (isSubscribed) {
267 setError("Error fetching post");
268 if (onEmpty) {
269 onEmpty({
270 code: "fetching_error",
271 message: "Error fetching post",
272 });
273 }
274 }
275 }
276 }
277 };
278
279 initializeUri();
280
281 return () => {
282 isSubscribed = false;
283 };
284 }, [propUri, author, onEmpty]);
285
286 useEffect(() => {
287 let isSubscribed = true;
288
289 const fetchThreadData = async () => {
290 if (!uri) return;
291
292 try {
293 const thread = await getPostThread(uri);
294 if (isSubscribed) {
295 setThread(thread);
296 }
297 } catch (err) {
298 if (isSubscribed) {
299 setError("Error loading comments");
300 if (onEmpty) {
301 onEmpty({
302 code: "comment_loading_error",
303 message: "Error loading comments",
304 });
305 }
306 }
307 }
308 };
309
310 fetchThreadData();
311
312 return () => {
313 isSubscribed = false;
314 };
315 }, [uri, onEmpty]);
316
317 const showMore = () => {
318 setVisibleCount((prevCount) => prevCount + 5);
319 };
320
321 if (!uri) return null;
322
323 if (error) {
324 return (
325 <div className="container">
326 <style>{styles}</style>
327 <p className="errorText">{error}</p>
328 </div>
329 );
330 }
331
332 if (!thread) {
333 return (
334 <div className="container">
335 <style>{styles}</style>
336 <p className="loadingText">Loading comments...</p>
337 </div>
338 );
339 }
340
341 let postUrl: string = uri;
342 if (uri.startsWith("at://")) {
343 const [, , did, _, rkey] = uri.split("/");
344 postUrl = `https://bsky.app/profile/${did}/post/${rkey}`;
345 }
346
347 if (!thread.replies || thread.replies.length === 0) {
348 return (
349 <div className="container">
350 <style>{styles}</style>
351 <PostSummary postUrl={postUrl} post={thread.post} />
352 </div>
353 );
354 }
355
356 // Safe sort - ensure we're working with valid objects
357 const sortedReplies = [...thread.replies].filter((reply) =>
358 AppBskyFeedDefs.isThreadViewPost(reply)
359 ).sort(sortByLikes);
360
361 return (
362 <div className="container">
363 <style>{styles}</style>
364 <PostSummary postUrl={postUrl} post={thread.post} />
365 <hr className="divider" />
366 <div className="commentsList">
367 {sortedReplies.slice(0, visibleCount).map((reply) => {
368 if (!AppBskyFeedDefs.isThreadViewPost(reply)) return null;
369 return (
370 <Comment
371 key={reply.post.uri}
372 comment={reply}
373 filters={commentFilters}
374 />
375 );
376 })}
377 {visibleCount < sortedReplies.length && (
378 <button onClick={showMore} className="showMoreButton">
379 Show more comments
380 </button>
381 )}
382 </div>
383 </div>
384 );
385};
386
387const getPostThread = async (
388 uri: string,
389): Promise<AppBskyFeedDefs.ThreadViewPost> => {
390 const atUri = getAtUri(uri);
391 const params = new URLSearchParams({ uri: atUri });
392
393 const res = await fetch(
394 "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?" +
395 params.toString(),
396 {
397 method: "GET",
398 headers: {
399 Accept: "application/json",
400 },
401 cache: "no-store",
402 },
403 );
404
405 if (!res.ok) {
406 console.error(await res.text());
407 throw new Error("Failed to fetch post thread");
408 }
409
410 const data = (await res.json()) as AppBskyFeedGetPostThread.OutputSchema;
411
412 if (!AppBskyFeedDefs.isThreadViewPost(data.thread)) {
413 throw new Error("Could not find thread");
414 }
415
416 return data.thread;
417};
418
419const sortByLikes = (a: unknown, b: unknown) => {
420 if (
421 !AppBskyFeedDefs.isThreadViewPost(a) ||
422 !AppBskyFeedDefs.isThreadViewPost(b) ||
423 !("post" in a) ||
424 !("post" in b)
425 ) {
426 return 0;
427 }
428 const aPost = a as AppBskyFeedDefs.ThreadViewPost;
429 const bPost = b as AppBskyFeedDefs.ThreadViewPost;
430 return (bPost.post.likeCount ?? 0) - (aPost.post.likeCount ?? 0);
431};