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};