A personal website powered by Astro and ATProto

jetstream live feed

Changed files
+114 -110
src
components
pages
+113 -5
src/components/content/ContentFeed.astro
··· 4 4 import type { AtprotoRecord } from '../../lib/types/atproto'; 5 5 import { extractCidFromBlobRef, blobCdnUrl } from '../../lib/atproto/blob'; 6 6 7 + 7 8 interface Props { 8 - handle: string; 9 9 collection?: string; 10 10 limit?: number; 11 11 feedUri?: string; 12 12 showAuthor?: boolean; 13 13 showTimestamp?: boolean; 14 + live?: boolean; 14 15 } 15 16 16 17 const { 17 - handle, 18 18 collection = 'app.bsky.feed.post', 19 19 limit = 10, 20 20 feedUri, 21 21 showAuthor = true, 22 - showTimestamp = true 22 + showTimestamp = true, 23 + live = false, 23 24 } = Astro.props; 24 25 25 26 const config = loadConfig(); 27 + const handle = config.atproto.handle; 26 28 const browser = new AtprotoBrowser(); 27 29 28 30 // Helper function to get image URL from blob reference ··· 61 63 62 64 <div class="space-y-6"> 63 65 {records.length > 0 ? ( 64 - <div class="space-y-4"> 66 + <div id="feed-container" class="space-y-4" data-show-timestamp={String(showTimestamp)} data-initial-limit={String(limit)} data-did={config.atproto.did}> 65 67 {records.map((record) => { 66 68 if (record.value?.$type !== 'app.bsky.feed.post') return null; 67 69 ··· 136 138 <p class="text-sm mt-2">Debug: Handle = {handle}, Records fetched = {records.length}</p> 137 139 </div> 138 140 )} 139 - </div> 141 + </div> 142 + 143 + {live && ( 144 + <script> 145 + // @ts-nocheck 146 + const container = document.getElementById('feed-container'); 147 + if (container) { 148 + const SHOW_TIMESTAMP = container.getAttribute('data-show-timestamp') === 'true'; 149 + const INITIAL_LIMIT = Number(container.getAttribute('data-initial-limit') || '10'); 150 + const maxPrepend = 20; 151 + const DID = container.getAttribute('data-did') || ''; 152 + 153 + function extractCid(ref) { 154 + if (typeof ref === 'string') return ref; 155 + if (ref && typeof ref === 'object') { 156 + if (typeof ref.$link === 'string') return ref.$link; 157 + if (typeof ref.toString === 'function') return ref.toString(); 158 + } 159 + return null; 160 + } 161 + 162 + function buildImagesEl(post, did) { 163 + if (post?.embed?.$type !== 'app.bsky.embed.images' || !post.embed.images) return null; 164 + const grid = document.createElement('div'); 165 + const count = post.embed.images.length; 166 + const cols = count === 1 ? 'grid-cols-1' : count === 2 ? 'grid-cols-2' : 'grid-cols-3'; 167 + grid.className = `grid gap-2 ${cols}`; 168 + for (const img of post.embed.images) { 169 + const cid = extractCid(img?.image?.ref); 170 + if (!cid) continue; 171 + const url = `https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`; 172 + const wrapper = document.createElement('div'); 173 + wrapper.className = 'relative'; 174 + const image = document.createElement('img'); 175 + image.src = url; 176 + image.alt = img?.alt || 'Post image'; 177 + image.className = 'rounded-lg w-full h-auto object-cover'; 178 + const arW = (img?.aspectRatio && img.aspectRatio.width) || 1; 179 + const arH = (img?.aspectRatio && img.aspectRatio.height) || 1; 180 + // @ts-ignore 181 + image.style.aspectRatio = `${arW} / ${arH}`; 182 + wrapper.appendChild(image); 183 + grid.appendChild(wrapper); 184 + } 185 + return grid; 186 + } 187 + 188 + function buildPostEl(post, did) { 189 + const article = document.createElement('article'); 190 + article.className = 'bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 mb-4'; 191 + 192 + const textDiv = document.createElement('div'); 193 + textDiv.className = 'text-gray-900 dark:text-white mb-3'; 194 + textDiv.textContent = post?.text ? String(post.text) : ''; 195 + article.appendChild(textDiv); 196 + 197 + const imagesEl = buildImagesEl(post, did); 198 + if (imagesEl) { 199 + const imagesWrap = document.createElement('div'); 200 + imagesWrap.className = 'mb-3'; 201 + imagesWrap.appendChild(imagesEl); 202 + article.appendChild(imagesWrap); 203 + } 204 + 205 + if (SHOW_TIMESTAMP && post?.createdAt) { 206 + const timeDiv = document.createElement('div'); 207 + timeDiv.className = 'text-xs text-gray-500 dark:text-gray-400'; 208 + timeDiv.textContent = new Date(post.createdAt).toLocaleDateString('en-US', { year:'numeric', month:'short', day:'numeric' }); 209 + article.appendChild(timeDiv); 210 + } 211 + 212 + return article; 213 + } 214 + 215 + try { 216 + const endpoint = 'wss://jetstream1.us-east.bsky.network/subscribe'; 217 + const url = new URL(endpoint); 218 + if (DID) url.searchParams.append('wantedDids', DID); 219 + const ws = new WebSocket(url.toString()); 220 + 221 + ws.onmessage = (event) => { 222 + try { 223 + const data = JSON.parse(event.data); 224 + if (data?.kind !== 'commit') return; 225 + const commit = data.commit; 226 + const record = commit?.record || {}; 227 + if (commit?.operation !== 'create') return; 228 + if (record?.$type !== 'app.bsky.feed.post') return; 229 + const el = buildPostEl(record, data.did); 230 + // @ts-ignore 231 + container.insertBefore(el, container.firstChild); 232 + const posts = container.children; 233 + if (posts.length > maxPrepend + INITIAL_LIMIT) { 234 + if (container.lastElementChild) container.removeChild(container.lastElementChild); 235 + } 236 + } catch (e) { 237 + console.error('jetstream msg error', e); 238 + } 239 + }; 240 + 241 + ws.onerror = (e) => console.error('jetstream ws error', e); 242 + } catch (e) { 243 + console.error('jetstream start error', e); 244 + } 245 + } 246 + </script> 247 + )}
+1 -105
src/pages/index.astro
··· 26 26 My Posts 27 27 </h2> 28 28 <ContentFeed 29 - handle={config.atproto.handle} 30 29 limit={10} 31 30 showAuthor={false} 32 31 showTimestamp={true} 32 + live={true} 33 33 /> 34 34 </section> 35 35 ··· 40 40 Explore More 41 41 </h2> 42 42 <div class="grid md:grid-cols-2 gap-6"> 43 - <a href="/content-feed" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 44 - <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 45 - Content Feed 46 - </h3> 47 - <p class="text-gray-600 dark:text-gray-400"> 48 - All your ATProto content with real-time updates and streaming. 49 - </p> 50 - </a> 51 43 <a href="/galleries" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 52 44 <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 53 45 Image Galleries 54 46 </h3> 55 47 <p class="text-gray-600 dark:text-gray-400"> 56 48 View my grain.social image galleries and photo collections. 57 - </p> 58 - </a> 59 - <a href="/galleries-unified" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 60 - <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 61 - Unified Galleries 62 - </h3> 63 - <p class="text-gray-600 dark:text-gray-400"> 64 - Galleries with real-time updates using the content system. 65 - </p> 66 - </a> 67 - <a href="/gallery-test" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 68 - <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 69 - Gallery System Test 70 - </h3> 71 - <p class="text-gray-600 dark:text-gray-400"> 72 - Test the gallery service and display components with detailed debugging. 73 - </p> 74 - </a> 75 - <a href="/content-test" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 76 - <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 77 - Content Rendering Test 78 - </h3> 79 - <p class="text-gray-600 dark:text-gray-400"> 80 - Test the content rendering system with type-safe components and filters. 81 - </p> 82 - </a> 83 - <a href="/gallery-debug" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 84 - <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 85 - Gallery Structure Debug 86 - </h3> 87 - <p class="text-gray-600 dark:text-gray-400"> 88 - Examine Grain.social collection structure and relationships. 89 - </p> 90 - </a> 91 - <a href="/grain-gallery-test" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 92 - <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 93 - Grain Gallery Test 94 - </h3> 95 - <p class="text-gray-600 dark:text-gray-400"> 96 - Test the Grain.social gallery grouping and display system. 97 - </p> 98 - </a> 99 - <a href="/api-debug" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 100 - <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 101 - API Debug 102 - </h3> 103 - <p class="text-gray-600 dark:text-gray-400"> 104 - Debug ATProto API calls and configuration issues. 105 - </p> 106 - </a> 107 - <a href="/simple-test" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 108 - <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 109 - Simple API Test 110 - </h3> 111 - <p class="text-gray-600 dark:text-gray-400"> 112 - Basic HTTP request test to ATProto APIs. 113 - </p> 114 - </a> 115 - <a href="/discovery-test" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 116 - <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 117 - Discovery Test 118 - </h3> 119 - <p class="text-gray-600 dark:text-gray-400"> 120 - Build-time collection discovery and type generation. 121 - </p> 122 - </a> 123 - <a href="/simplified-test" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 124 - <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 125 - Simplified Content Test 126 - </h3> 127 - <p class="text-gray-600 dark:text-gray-400"> 128 - Test the simplified content service approach based on reference repository patterns. 129 - </p> 130 - </a> 131 - <a href="/jetstream-test" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 132 - <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 133 - Jetstream Test 134 - </h3> 135 - <p class="text-gray-600 dark:text-gray-400"> 136 - ATProto sync API streaming with DID filtering (like atptools) - low latency, real-time. 137 - </p> 138 - </a> 139 - <a href="/atproto-browser-test" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 140 - <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 141 - ATProto Browser Test 142 - </h3> 143 - <p class="text-gray-600 dark:text-gray-400"> 144 - Browse ATProto accounts and records like atptools - explore collections and records. 145 - </p> 146 - </a> 147 - <a href="/lexicon-generator-test" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 148 - <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 149 - Lexicon Generator Test 150 - </h3> 151 - <p class="text-gray-600 dark:text-gray-400"> 152 - Generate TypeScript types for all lexicons discovered in your configured repository. 153 49 </p> 154 50 </a> 155 51 </div>