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