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