my website at ewancroft.uk
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(bluesky): add polling, reply, and repost support to BlueskyPostCard

- Implemented automatic polling (every 2 minutes) to fetch new Bluesky posts
- Enhanced UI to display replies, reposts, and nested quoted posts with better structure
- Added cleanup via onDestroy to clear polling intervals
- Updated fetchLatestBlueskyPost to include replies and reposts via author feed
- Introduced replyParent/replyRoot and repost metadata to BlueskyPost type
- Added cache deletion utility method for flexibility
- Improved console logging and error handling for debugging

+353 -234
+280 -203
src/lib/components/layout/main/card/BlueskyPostCard.svelte
··· 1 1 <script lang="ts"> 2 - import { onMount } from 'svelte'; 2 + import { onMount, onDestroy } from 'svelte'; 3 3 import { Card } from '$lib/components/ui'; 4 4 import { fetchLatestBlueskyPost, type BlueskyPost } from '$lib/services/atproto'; 5 5 import { formatRelativeTime } from '$lib/utils/formatDate'; ··· 10 10 let loading = true; 11 11 let error: string | null = null; 12 12 let lightboxImage: { url: string; alt: string } | null = null; 13 + let pollInterval: ReturnType<typeof setInterval> | null = null; 13 14 14 15 // Detect system locale, fallback to en-GB 15 16 const locale = typeof navigator !== 'undefined' ? navigator.language || 'en-GB' : 'en-GB'; 16 17 17 - onMount(async () => { 18 + // Poll interval in milliseconds (2 minutes) 19 + const POLL_INTERVAL = 2 * 60 * 1000; 20 + 21 + async function loadPost() { 18 22 try { 19 - post = await fetchLatestBlueskyPost(); 23 + const newPost = await fetchLatestBlueskyPost(); 24 + if (newPost && (!post || newPost.uri !== post.uri)) { 25 + // New post detected 26 + post = newPost; 27 + console.log('[BlueskyPostCard] New post detected:', newPost.uri); 28 + } 20 29 } catch (err) { 21 30 error = err instanceof Error ? err.message : 'Failed to load latest post'; 31 + console.error('[BlueskyPostCard] Error loading post:', err); 22 32 } finally { 23 33 loading = false; 34 + } 35 + } 36 + 37 + onMount(async () => { 38 + // Initial load 39 + await loadPost(); 40 + 41 + // Set up polling for new posts 42 + pollInterval = setInterval(async () => { 43 + console.log('[BlueskyPostCard] Polling for new posts...'); 44 + await loadPost(); 45 + }, POLL_INTERVAL); 46 + }); 47 + 48 + onDestroy(() => { 49 + // Clean up interval on component destroy 50 + if (pollInterval) { 51 + clearInterval(pollInterval); 52 + pollInterval = null; 24 53 } 25 54 }); 26 55 ··· 86 115 } 87 116 </script> 88 117 89 - {#snippet postContent(postData: BlueskyPost, depth: number = 0, isQuoted: boolean = false)} 90 - <article 91 - class="rounded-xl bg-canvas-{isQuoted ? '200' : '100'} p-{isQuoted ? '4' : '6'} {isQuoted 92 - ? 'border border-canvas-300 dark:border-canvas-700' 93 - : 'shadow-lg'} transition-all duration-300 {isQuoted 94 - ? '' 95 - : 'hover:shadow-xl'} dark:bg-canvas-{isQuoted ? '800' : '900'}" 96 - > 97 - <!-- Header (only show on root post) --> 98 - {#if !isQuoted} 99 - <div class="mb-4 flex items-center justify-between"> 100 - <span class="text-xs font-semibold tracking-wide text-ink-800 uppercase dark:text-ink-100"> 101 - Latest Bluesky Post 102 - </span> 118 + {#snippet postContent(postData: BlueskyPost, depth: number = 0, isReplyParent: boolean = false)} 119 + <div> 120 + <!-- Author Info --> 121 + <div class="flex gap-{isReplyParent ? '2' : '3'} relative"> 122 + {#if isReplyParent} 103 123 <a 104 - href={getPostUrl(postData.uri)} 124 + href={getProfileUrl(postData.author.handle)} 105 125 target="_blank" 106 126 rel="noopener noreferrer" 107 - class="text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300" 108 - aria-label="View post on Bluesky" 127 + class="transition-opacity hover:opacity-80 shrink-0" 109 128 > 110 - <ExternalLink class="h-4 w-4" aria-hidden="true" /> 129 + {#if postData.author.avatar} 130 + <img 131 + src={postData.author.avatar} 132 + alt={postData.author.displayName || postData.author.handle} 133 + class="h-10 w-10 rounded-full object-cover" 134 + loading="lazy" 135 + /> 136 + {:else} 137 + <div 138 + class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-200 dark:bg-primary-800" 139 + > 140 + <span class="text-base font-semibold text-primary-700 dark:text-primary-300"> 141 + {(postData.author.displayName || postData.author.handle).charAt(0).toUpperCase()} 142 + </span> 143 + </div> 144 + {/if} 111 145 </a> 112 - </div> 113 - {/if} 114 - 115 - <!-- Author Info --> 116 - <a 117 - href={getProfileUrl(postData.author.handle)} 118 - target="_blank" 119 - rel="noopener noreferrer" 120 - class="mb-{isQuoted ? '3' : '4'} flex items-center gap-{isQuoted 121 - ? '2' 122 - : '3'} transition-opacity hover:opacity-80" 123 - > 124 - {#if postData.author.avatar} 125 - <img 126 - src={postData.author.avatar} 127 - alt={postData.author.displayName || postData.author.handle} 128 - class="h-{isQuoted ? '10' : '12'} w-{isQuoted ? '10' : '12'} rounded-full object-cover" 129 - loading="lazy" 130 - /> 131 146 {:else} 147 + <a 148 + href={getProfileUrl(postData.author.handle)} 149 + target="_blank" 150 + rel="noopener noreferrer" 151 + class="transition-opacity hover:opacity-80 shrink-0" 152 + > 153 + {#if postData.author.avatar} 154 + <img 155 + src={postData.author.avatar} 156 + alt={postData.author.displayName || postData.author.handle} 157 + class="h-12 w-12 rounded-full object-cover" 158 + loading="lazy" 159 + /> 160 + {:else} 161 + <div 162 + class="flex h-12 w-12 items-center justify-center rounded-full bg-primary-200 dark:bg-primary-800" 163 + > 164 + <span class="text-lg font-semibold text-primary-700 dark:text-primary-300"> 165 + {(postData.author.displayName || postData.author.handle).charAt(0).toUpperCase()} 166 + </span> 167 + </div> 168 + {/if} 169 + </a> 170 + {/if} 171 + <div class="flex-1 min-w-0"> 172 + <!-- Author name and handle --> 173 + <a 174 + href={getProfileUrl(postData.author.handle)} 175 + target="_blank" 176 + rel="noopener noreferrer" 177 + class="inline-block {isReplyParent ? 'mb-1' : 'mb-2'} transition-opacity hover:opacity-80" 178 + > 179 + <div class="flex flex-col"> 180 + <span class="text-{isReplyParent ? 'sm' : 'base'} font-semibold text-ink-900 dark:text-ink-50 leading-tight"> 181 + {postData.author.displayName || postData.author.handle} 182 + </span> 183 + <span class="text-xs text-ink-600 dark:text-ink-400 leading-tight"> 184 + @{postData.author.handle} 185 + </span> 186 + </div> 187 + </a> 188 + 189 + <!-- Post Text with Rich Text Support --> 132 190 <div 133 - class="flex h-{isQuoted ? '10' : '12'} w-{isQuoted 134 - ? '10' 135 - : '12'} items-center justify-center rounded-full bg-primary-200 dark:bg-primary-800" 191 + class="{isReplyParent ? 'mb-2' : 'mb-3'} overflow-wrap-anywhere break-words whitespace-pre-wrap text-{isReplyParent 192 + ? 'sm' 193 + : 'base'} leading-relaxed text-ink-900 dark:text-ink-50" 136 194 > 137 - <span 138 - class="text-{isQuoted ? 'base' : 'lg'} font-semibold text-primary-700 dark:text-primary-300" 139 - > 140 - {(postData.author.displayName || postData.author.handle).charAt(0).toUpperCase()} 141 - </span> 195 + {@html renderRichText(postData.text, postData.facets)} 142 196 </div> 143 - {/if} 144 - <div class="flex flex-col"> 145 - <span class="text-{isQuoted ? 'sm' : 'base'} font-semibold text-ink-900 dark:text-ink-50"> 146 - {postData.author.displayName || postData.author.handle} 147 - </span> 148 - <span class="text-{isQuoted ? 'xs' : 'sm'} text-ink-700 dark:text-ink-200"> 149 - @{postData.author.handle} 150 - </span> 151 - </div> 152 - {#if isQuoted} 153 - <ExternalLink 154 - class="ml-auto h-4 w-4 flex-shrink-0 text-ink-700 transition-colors dark:text-ink-200" 155 - aria-hidden="true" 156 - /> 157 - {/if} 158 - </a> 159 197 160 - <!-- Post Text with Rich Text Support --> 161 - <div 162 - class="mb-{isQuoted ? '3' : '4'} overflow-wrap-anywhere break-words whitespace-pre-wrap text-{isQuoted 163 - ? 'base' 164 - : 'lg'} leading-relaxed text-ink-900 dark:text-ink-50" 165 - > 166 - {@html renderRichText(postData.text, postData.facets)} 167 - </div> 198 + <!-- Video --> 199 + {#if postData.hasVideo && postData.videoUrl} 200 + <div class="{isReplyParent ? 'mb-2' : 'mb-3'} max-w-full overflow-hidden rounded-xl bg-black border border-canvas-300 dark:border-canvas-700"> 201 + <video 202 + src={postData.videoUrl} 203 + controls 204 + class="w-full max-w-full" 205 + preload="metadata" 206 + poster={postData.videoThumbnail} 207 + playsinline 208 + > 209 + <track kind="captions" /> 210 + Your browser does not support the video tag. 211 + </video> 212 + </div> 213 + {/if} 168 214 169 - <!-- Video --> 170 - {#if postData.hasVideo && postData.videoUrl} 171 - <div class="mb-{isQuoted ? '3' : '4'} max-w-full overflow-hidden rounded-lg"> 172 - <video 173 - src={postData.videoUrl} 174 - controls 175 - class="w-full max-w-full" 176 - preload="metadata" 177 - poster={postData.videoThumbnail} 178 - > 179 - <track kind="captions" /> 180 - </video> 181 - </div> 182 - {/if} 215 + <!-- Images --> 216 + {#if postData.hasImages && postData.imageUrls && postData.imageUrls.length > 0} 217 + <div 218 + class="{isReplyParent ? 'mb-2' : 'mb-3'} grid max-w-full gap-1 {postData.imageUrls.length === 1 219 + ? 'grid-cols-1' 220 + : postData.imageUrls.length === 2 221 + ? 'grid-cols-2' 222 + : postData.imageUrls.length === 3 223 + ? 'grid-cols-3' 224 + : 'grid-cols-2'}" 225 + > 226 + {#each postData.imageUrls as imageUrl, index} 227 + <button 228 + type="button" 229 + onclick={() => 230 + openLightbox(imageUrl, postData.imageAlts?.[index] || `Post attachment ${index + 1}`)} 231 + class="h-auto w-full max-w-full overflow-hidden rounded-lg transition-opacity hover:opacity-90 focus:ring-2 focus:ring-primary-500 focus:outline-none dark:focus:ring-primary-400 border border-canvas-300 dark:border-canvas-700" 232 + title={postData.imageAlts?.[index] || `Post attachment ${index + 1}`} 233 + > 234 + <img 235 + src={imageUrl} 236 + alt={postData.imageAlts?.[index] || `Post attachment ${index + 1}`} 237 + title={postData.imageAlts?.[index] || `Post attachment ${index + 1}`} 238 + class="h-auto w-full max-w-full object-cover {postData.imageUrls.length === 4 239 + ? 'aspect-square' 240 + : postData.imageUrls.length > 1 241 + ? 'aspect-video' 242 + : isReplyParent 243 + ? 'max-h-64' 244 + : 'max-h-96'}" 245 + loading="lazy" 246 + /> 247 + </button> 248 + {/each} 249 + </div> 250 + {/if} 183 251 184 - <!-- Images --> 185 - {#if postData.hasImages && postData.imageUrls && postData.imageUrls.length > 0} 186 - <div 187 - class="mb-{isQuoted ? '3' : '4'} grid max-w-full gap-2 {postData.imageUrls.length === 1 188 - ? 'grid-cols-1' 189 - : postData.imageUrls.length === 2 190 - ? 'grid-cols-2' 191 - : postData.imageUrls.length === 3 192 - ? 'grid-cols-3' 193 - : 'grid-cols-2'}" 194 - > 195 - {#each postData.imageUrls as imageUrl, index} 196 - <button 197 - type="button" 198 - onclick={() => 199 - openLightbox(imageUrl, postData.imageAlts?.[index] || `Post attachment ${index + 1}`)} 200 - class="h-auto w-full max-w-full overflow-hidden rounded-lg transition-opacity hover:opacity-90 focus:ring-2 focus:ring-primary-500 focus:outline-none dark:focus:ring-primary-400" 201 - title={postData.imageAlts?.[index] || `Post attachment ${index + 1}`} 252 + <!-- External Link Card --> 253 + {#if postData.externalLink} 254 + <a 255 + href={postData.externalLink.uri} 256 + target="_blank" 257 + rel="noopener noreferrer" 258 + class="{isReplyParent ? 'mb-2' : 'mb-3'} flex max-w-full flex-col overflow-hidden rounded-xl border border-canvas-300 bg-canvas-200 transition-colors hover:bg-canvas-300 dark:border-canvas-700 dark:bg-canvas-800 dark:hover:bg-canvas-700" 202 259 > 203 - <img 204 - src={imageUrl} 205 - alt={postData.imageAlts?.[index] || `Post attachment ${index + 1}`} 206 - title={postData.imageAlts?.[index] || `Post attachment ${index + 1}`} 207 - class="h-auto w-full max-w-full object-cover {postData.imageUrls.length === 4 208 - ? 'aspect-square' 209 - : postData.imageUrls.length > 1 210 - ? 'aspect-video' 211 - : isQuoted 212 - ? 'max-h-64' 213 - : 'max-h-96'}" 214 - loading="lazy" 215 - /> 216 - </button> 217 - {/each} 218 - </div> 219 - {/if} 260 + {#if postData.externalLink.thumb} 261 + <img 262 + src={postData.externalLink.thumb} 263 + alt={postData.externalLink.title} 264 + class="h-48 w-full max-w-full object-cover" 265 + loading="lazy" 266 + /> 267 + {/if} 268 + <div class="p-3"> 269 + <h3 270 + class="mb-1 overflow-wrap-anywhere break-words text-sm font-semibold text-ink-900 dark:text-ink-50 line-clamp-2" 271 + > 272 + {postData.externalLink.title} 273 + </h3> 274 + {#if postData.externalLink.description} 275 + <p 276 + class="mb-2 overflow-wrap-anywhere break-words text-xs text-ink-700 dark:text-ink-300 line-clamp-2" 277 + > 278 + {postData.externalLink.description} 279 + </p> 280 + {/if} 281 + <p class="overflow-wrap-anywhere break-words text-xs text-ink-600 dark:text-ink-400"> 282 + {new URL(postData.externalLink.uri).hostname} 283 + </p> 284 + </div> 285 + </a> 286 + {/if} 220 287 221 - <!-- External Link Card --> 222 - {#if postData.externalLink} 223 - <a 224 - href={postData.externalLink.uri} 225 - target="_blank" 226 - rel="noopener noreferrer" 227 - class="mb-{isQuoted 228 - ? '3' 229 - : '4'} flex max-w-full flex-col gap-2 overflow-hidden rounded-lg border border-canvas-300 bg-canvas-{isQuoted 230 - ? '300' 231 - : '200'} transition-colors hover:bg-canvas-{isQuoted 232 - ? '400' 233 - : '300'} dark:border-canvas-700 dark:bg-canvas-{isQuoted 234 - ? '700' 235 - : '800'} dark:hover:bg-canvas-{isQuoted ? '600' : '700'}" 236 - > 237 - {#if postData.externalLink.thumb} 238 - <img 239 - src={postData.externalLink.thumb} 240 - alt={postData.externalLink.title} 241 - class="h-{isQuoted ? '32' : '48'} w-full max-w-full object-cover" 242 - loading="lazy" 243 - /> 288 + <!-- Recursively render quoted post --> 289 + {#if postData.quotedPost && depth < 3} 290 + <div class="{isReplyParent ? 'mb-2' : 'mb-3'} rounded-xl border border-canvas-300 bg-canvas-200 p-3 dark:border-canvas-700 dark:bg-canvas-800"> 291 + {@render postContent(postData.quotedPost, depth + 1, depth === 0)} 292 + </div> 244 293 {/if} 245 - <div class="p-{isQuoted ? '3' : '4'}"> 246 - <h3 247 - class="mb-1 overflow-wrap-anywhere break-words text-{isQuoted 248 - ? 'sm' 249 - : 'base'} line-clamp-2 font-semibold text-ink-900 dark:text-ink-50" 250 - > 251 - {postData.externalLink.title} 252 - </h3> 253 - {#if postData.externalLink.description} 254 - <p 255 - class="mb-2 overflow-wrap-anywhere break-words text-{isQuoted ? 'xs' : 'sm'} line-clamp-2 text-ink-700 dark:text-ink-200" 256 - > 257 - {postData.externalLink.description} 258 - </p> 259 - {/if} 260 - <p class="overflow-wrap-anywhere break-words text-xs text-ink-600 dark:text-ink-300"> 261 - {new URL(postData.externalLink.uri).hostname} 262 - </p> 263 - </div> 264 - </a> 265 - {/if} 266 294 267 - <!-- Recursively render quoted post --> 268 - {#if postData.quotedPost && depth < 2} 269 - <div class="mb-{isQuoted ? '3' : '4'}"> 270 - {@render postContent(postData.quotedPost, depth + 1, true)} 271 - </div> 272 - {/if} 295 + <!-- Engagement Stats (only for non-reply-parent posts) --> 296 + {#if !isReplyParent} 297 + <div class="flex items-center gap-6 text-sm pt-1"> 298 + {#if postData.replyCount !== undefined} 299 + <div class="flex items-center gap-1.5 text-ink-600 dark:text-ink-400"> 300 + <MessageCircle class="h-4 w-4" aria-hidden="true" /> 301 + <span class="font-medium">{formatCompactNumber(postData.replyCount, locale)}</span> 302 + </div> 303 + {/if} 273 304 274 - <!-- Engagement Stats --> 275 - {#if depth === 0 || (depth === 1 && !postData.quotedPost)} 276 - <div class="flex items-center gap-{isQuoted ? '4' : '6'} text-{isQuoted ? 'xs' : 'sm'}"> 277 - {#if postData.replyCount !== undefined} 278 - <div class="flex items-center gap-1.5 text-ink-700 dark:text-ink-200"> 279 - <MessageCircle 280 - class="h-{isQuoted ? '3' : '4'} w-{isQuoted ? '3' : '4'}" 281 - aria-hidden="true" 282 - /> 283 - <span class="font-medium">{formatCompactNumber(postData.replyCount, locale)}</span> 284 - </div> 285 - {/if} 305 + {#if postData.repostCount !== undefined} 306 + <div class="flex items-center gap-1.5 text-ink-600 dark:text-ink-400"> 307 + <Repeat2 class="h-4 w-4" aria-hidden="true" /> 308 + <span class="font-medium">{formatCompactNumber(postData.repostCount, locale)}</span> 309 + </div> 310 + {/if} 286 311 287 - {#if postData.repostCount !== undefined} 288 - <div class="flex items-center gap-1.5 text-ink-700 dark:text-ink-200"> 289 - <Repeat2 class="h-{isQuoted ? '3' : '4'} w-{isQuoted ? '3' : '4'}" aria-hidden="true" /> 290 - <span class="font-medium">{formatCompactNumber(postData.repostCount, locale)}</span> 291 - </div> 292 - {/if} 312 + {#if postData.likeCount !== undefined} 313 + <div class="flex items-center gap-1.5 text-ink-600 dark:text-ink-400"> 314 + <Heart class="h-4 w-4" aria-hidden="true" /> 315 + <span class="font-medium">{formatCompactNumber(postData.likeCount, locale)}</span> 316 + </div> 317 + {/if} 293 318 294 - {#if postData.likeCount !== undefined} 295 - <div class="flex items-center gap-1.5 text-ink-700 dark:text-ink-200"> 296 - <Heart class="h-{isQuoted ? '3' : '4'} w-{isQuoted ? '3' : '4'}" aria-hidden="true" /> 297 - <span class="font-medium">{formatCompactNumber(postData.likeCount, locale)}</span> 319 + <time 320 + datetime={postData.createdAt} 321 + class="ml-auto text-xs font-medium text-ink-700 dark:text-ink-300" 322 + > 323 + {formatRelativeTime(postData.createdAt)} 324 + </time> 298 325 </div> 299 326 {/if} 300 - 301 - <time 302 - datetime={postData.createdAt} 303 - class="ml-auto text-xs font-medium text-ink-800 dark:text-ink-100" 304 - > 305 - {formatRelativeTime(postData.createdAt)} 306 - </time> 307 327 </div> 308 - {/if} 309 - </article> 328 + </div> 329 + </div> 310 330 {/snippet} 311 331 312 332 <div class="mx-auto w-full max-w-2xl"> ··· 346 366 {:else if error} 347 367 <Card error={true} errorMessage={error} /> 348 368 {:else if post} 349 - {@render postContent(post, 0, false)} 369 + <article class="rounded-xl bg-canvas-100 p-6 shadow-lg transition-all duration-300 hover:shadow-xl dark:bg-canvas-900"> 370 + <!-- Header --> 371 + <div class="mb-4 flex items-center justify-between"> 372 + <div class="flex items-center gap-2"> 373 + <span class="text-xs font-semibold tracking-wide text-ink-700 uppercase dark:text-ink-300"> 374 + Latest Bluesky Post 375 + </span> 376 + {#if post.isRepost && post.repostAuthor} 377 + <span class="text-xs text-ink-600 dark:text-ink-400">·</span> 378 + <div class="flex items-center gap-1.5 text-xs text-ink-600 dark:text-ink-400"> 379 + <Repeat2 class="h-3 w-3" aria-hidden="true" /> 380 + <a 381 + href={getProfileUrl(post.repostAuthor.handle)} 382 + target="_blank" 383 + rel="noopener noreferrer" 384 + class="font-medium text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300" 385 + > 386 + {post.repostAuthor.displayName || post.repostAuthor.handle} 387 + </a> 388 + <span>reposted</span> 389 + </div> 390 + {:else if post.replyParent} 391 + <span class="text-xs text-ink-600 dark:text-ink-400">·</span> 392 + <div class="flex items-center gap-1.5 text-xs text-ink-600 dark:text-ink-400"> 393 + <MessageCircle class="h-3 w-3" aria-hidden="true" /> 394 + <span>Replying to</span> 395 + <a 396 + href={getProfileUrl(post.replyParent.author.handle)} 397 + target="_blank" 398 + rel="noopener noreferrer" 399 + class="font-medium text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300" 400 + > 401 + @{post.replyParent.author.handle} 402 + </a> 403 + </div> 404 + {/if} 405 + </div> 406 + <a 407 + href={getPostUrl(post.uri)} 408 + target="_blank" 409 + rel="noopener noreferrer" 410 + class="text-ink-600 transition-colors hover:text-primary-600 dark:text-ink-400 dark:hover:text-primary-400" 411 + aria-label="View post on Bluesky" 412 + > 413 + <ExternalLink class="h-4 w-4" aria-hidden="true" /> 414 + </a> 415 + </div> 416 + 417 + <!-- Reply Context --> 418 + {#if post.replyParent} 419 + <div class="mb-4 rounded-xl border border-canvas-300 bg-canvas-200 p-3 dark:border-canvas-700 dark:bg-canvas-800"> 420 + {@render postContent(post.replyParent, 0, true)} 421 + </div> 422 + {/if} 423 + 424 + <!-- Main Post --> 425 + {@render postContent(post, 0, false)} 426 + </article> 350 427 {:else} 351 428 <Card variant="flat" padding="lg"> 352 429 {#snippet children()}
+4
src/lib/services/atproto/cache.ts
··· 26 26 }); 27 27 } 28 28 29 + delete(key: string): void { 30 + this.cache.delete(key); 31 + } 32 + 29 33 clear(): void { 30 34 this.cache.clear(); 31 35 }
+61 -31
src/lib/services/atproto/posts.ts
··· 192 192 } 193 193 194 194 /** 195 - * Fetches the latest non-reply Bluesky post 195 + * Fetches the latest Bluesky post (including replies and reposts) 196 196 */ 197 197 export async function fetchLatestBlueskyPost(): Promise<BlueskyPost | null> { 198 198 console.log('[fetchLatestBlueskyPost] Starting fetch...'); ··· 204 204 } 205 205 206 206 try { 207 - console.log('[fetchLatestBlueskyPost] Fetching records from repo...'); 208 - const records = await withFallback( 209 - PUBLIC_ATPROTO_DID, 210 - async (agent) => { 211 - const response = await agent.com.atproto.repo.listRecords({ 212 - repo: PUBLIC_ATPROTO_DID, 213 - collection: 'app.bsky.feed.post', 214 - limit: 10 215 - }); 216 - return response.data.records; 217 - }, 218 - true 219 - ); 207 + console.log('[fetchLatestBlueskyPost] Fetching author feed...'); 208 + // Use getAuthorFeed to get posts AND reposts in chronological order 209 + const feedResponse = await defaultAgent.getAuthorFeed({ 210 + actor: PUBLIC_ATPROTO_DID, 211 + limit: 5 212 + }); 220 213 221 - console.log('[fetchLatestBlueskyPost] Records fetched:', records.length); 214 + const feed = feedResponse.data.feed; 215 + console.log('[fetchLatestBlueskyPost] Feed items fetched:', feed.length); 222 216 223 - if (records.length === 0) { 224 - console.warn('[fetchLatestBlueskyPost] No records found'); 217 + if (feed.length === 0) { 218 + console.warn('[fetchLatestBlueskyPost] No feed items found'); 225 219 return null; 226 220 } 227 221 228 - const nonReplyPost = records.find((record) => { 229 - const value = record.value as any; 230 - return !value.reply; 231 - }); 232 - 233 - if (!nonReplyPost) { 234 - console.warn('[fetchLatestBlueskyPost] No non-reply post found'); 235 - return null; 222 + // Take the latest feed item (first in array) 223 + const latestFeedItem = feed[0]; 224 + const latestPostData = latestFeedItem.post; 225 + console.log('[fetchLatestBlueskyPost] Found latest feed item:', latestPostData.uri); 226 + 227 + // Check if this is a repost 228 + const isRepost = latestFeedItem.reason?.$type === 'app.bsky.feed.defs#reasonRepost'; 229 + let repostAuthor: PostAuthor | undefined; 230 + let repostCreatedAt: string | undefined; 231 + 232 + if (isRepost && latestFeedItem.reason) { 233 + const reason = latestFeedItem.reason as any; 234 + repostAuthor = { 235 + did: reason.by.did, 236 + handle: reason.by.handle, 237 + displayName: reason.by.displayName, 238 + avatar: reason.by.avatar 239 + }; 240 + repostCreatedAt = reason.indexedAt; 241 + console.log('[fetchLatestBlueskyPost] This is a repost by:', repostAuthor.handle); 236 242 } 237 - 238 - console.log('[fetchLatestBlueskyPost] Found non-reply post:', nonReplyPost.uri); 239 - const post = await fetchPostFromUri(nonReplyPost.uri, 0); 240 - 243 + 244 + // Fetch the full post data 245 + const post = await fetchPostFromUri(latestPostData.uri, 0); 246 + 241 247 if (!post) { 242 248 console.warn('[fetchLatestBlueskyPost] fetchPostFromUri returned null'); 243 249 return null; 250 + } 251 + 252 + // Add repost context if applicable 253 + if (isRepost) { 254 + post.isRepost = true; 255 + post.repostAuthor = repostAuthor; 256 + post.repostCreatedAt = repostCreatedAt; 257 + // Store the original post data 258 + post.originalPost = { ...post }; 244 259 } 245 260 246 261 console.log('[fetchLatestBlueskyPost] Post fetched successfully, caching...'); ··· 258 273 export async function fetchPostFromUri(uri: string, depth: number): Promise<BlueskyPost | null> { 259 274 console.log(`[fetchPostFromUri] Starting fetch at depth ${depth} for URI:`, uri); 260 275 261 - if (depth >= 2) { 276 + if (depth >= 3) { 262 277 console.log(`[fetchPostFromUri] Max depth reached (${depth}), stopping recursion`); 263 278 return null; 264 279 } ··· 442 457 } 443 458 } 444 459 460 + // Handle reply context 461 + let replyParent: BlueskyPost | undefined; 462 + let replyRoot: BlueskyPost | undefined; 463 + if (value.reply) { 464 + console.log(`[fetchPostFromUri] Post is a reply, fetching parent...`); 465 + if (value.reply.parent?.uri) { 466 + replyParent = (await fetchPostFromUri(value.reply.parent.uri, depth + 1)) ?? undefined; 467 + } 468 + if (value.reply.root?.uri && value.reply.root.uri !== value.reply.parent?.uri) { 469 + replyRoot = (await fetchPostFromUri(value.reply.root.uri, depth + 1)) ?? undefined; 470 + } 471 + } 472 + 445 473 const post: BlueskyPost = { 446 474 text: value.text, 447 475 createdAt: value.createdAt, ··· 459 487 quotedPostUri, 460 488 quotedPost, 461 489 facets, 462 - externalLink 490 + externalLink, 491 + replyParent, 492 + replyRoot 463 493 }; 464 494 465 495 console.log(`[fetchPostFromUri] Post construction complete at depth ${depth}:`, {
+8
src/lib/services/atproto/types.ts
··· 170 170 quotedPost?: BlueskyPost; 171 171 facets?: Facet[]; 172 172 externalLink?: ExternalLink; 173 + // Reply context 174 + replyParent?: BlueskyPost; 175 + replyRoot?: BlueskyPost; 176 + // Repost context 177 + isRepost?: boolean; 178 + repostAuthor?: PostAuthor; 179 + repostCreatedAt?: string; 180 + originalPost?: BlueskyPost; 173 181 } 174 182 175 183 export interface ResolvedIdentity {