this repo has no description
1
fork

Configure Feed

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

enhanced fetching, new environment variable to change bsky.app, and more qol changes.

scanash00 3d9abbe0 fd090b66

+288 -38
+8
.env.example
··· 1 + # PDS URL 2 + NEXT_PUBLIC_PDS_URL= 3 + # PDS API KEY (PDS_ADMIN_PASSWORD) on Bluesky PDS 4 + PDS_PASSWORD= 5 + 6 + 7 + # BSKY APP URL (defaults to https://bsky.app) 8 + NEXT_PUBLIC_BSKY_APP_URL=
+1
.gitignore
··· 32 32 33 33 # env files (can opt-in for committing if needed) 34 34 .env* 35 + !.env.example 35 36 36 37 # vercel 37 38 .vercel
+21
LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2025 scanash.com 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+9
README.md
··· 29 29 PDS_PASSWORD=bleblebleble 30 30 ``` 31 31 32 + If wanted, change the BSKY_APP_URL to your preferred Bluesky app fork: 33 + 34 + ```bash 35 + NEXT_PUBLIC_BSKY_APP_URL=https://witchsky.app 36 + ``` 37 + 32 38 Build: 33 39 34 40 ```bash ··· 46 52 Uh, you will figure it out, just use Traefik/Caddy to serve this on / 47 53 48 54 Here's a guide that might be helpful: [Setting a custom homepage on a PDS](https://willdot.leaflet.pub/3m25uvnuwnk2t) 55 + 56 + # License 57 + Licensed under the MIT License
+48
src/app/api/posts/route.ts
··· 1 + import {NextResponse} from 'next/server'; 2 + import {getUsers, getLatestPosts, Post} from '@/lib/atproto'; 3 + 4 + let allPostsCache: Post[] = []; 5 + let lastFetchTime = 0; 6 + const CACHE_DURATION = 30 * 1000; 7 + 8 + export async function GET(request: Request) { 9 + try { 10 + const {searchParams} = new URL(request.url); 11 + const offset = parseInt(searchParams.get('offset') || '0'); 12 + const limit = parseInt(searchParams.get('limit') || '50'); 13 + const now = Date.now(); 14 + if (allPostsCache.length === 0 || now - lastFetchTime > CACHE_DURATION) { 15 + console.log('Fetching all posts from all users...'); 16 + const users = await getUsers(); 17 + const postsPromises = users.map(async user => { 18 + return await getLatestPosts(user, 999); 19 + }); 20 + 21 + const postsArrays = await Promise.all(postsPromises); 22 + 23 + allPostsCache = postsArrays 24 + .flatMap(p => p.posts) 25 + .sort((a, b) => { 26 + return new Date(b.record.createdAt).getTime() - new Date(a.record.createdAt).getTime(); 27 + }); 28 + 29 + lastFetchTime = now; 30 + console.log(`Cached ${allPostsCache.length} total posts from ${users.length} users`); 31 + } 32 + const batch = allPostsCache.slice(offset, offset + limit); 33 + const hasMore = offset + limit < allPostsCache.length; 34 + 35 + return NextResponse.json({ 36 + posts: batch, 37 + hasMore, 38 + total: allPostsCache.length, 39 + offset: offset + batch.length, 40 + }); 41 + } catch (error) { 42 + console.error('Error fetching posts:', error); 43 + return NextResponse.json( 44 + {posts: [], hasMore: false, total: 0, error: 'Failed to fetch posts'}, 45 + {status: 500} 46 + ); 47 + } 48 + }
+6 -12
src/app/page.tsx
··· 1 - import {getUsers, getLatestPosts, Post} from '@/lib/atproto'; 1 + import {getUsers, getAllLatestPosts, PDS_URL} from '@/lib/atproto'; 2 2 import {UserList} from '@/components/UserList'; 3 3 import {PostFeed} from '@/components/PostFeed'; 4 4 ··· 7 7 export const dynamic = 'force-dynamic'; 8 8 9 9 export default async function Home() { 10 - const pdsUrl = process.env.NEXT_PUBLIC_PDS_URL || 'https://bsky.social'; 10 + const pdsUrl = PDS_URL; 11 11 let pdsHostname = pdsUrl; 12 12 try { 13 13 pdsHostname = new URL(pdsUrl).hostname; ··· 15 15 // fuck off eslint 16 16 } 17 17 18 - const users = await getUsers(50); 19 - 20 - const postsPromises = users.map(user => getLatestPosts(user, 5)); 21 - const postsArrays = await Promise.all(postsPromises); 22 - 23 - const allPosts: Post[] = postsArrays.flat().sort((a, b) => { 24 - return new Date(b.record.createdAt).getTime() - new Date(a.record.createdAt).getTime(); 25 - }); 18 + const users = await getUsers(); 19 + const allPosts = await getAllLatestPosts(1000); 26 20 27 21 return ( 28 - <main className="min-h-screen halftone p-4 md:p-8 relative z-10"> 22 + <main className="min-h-screen p-4 md:p-8 relative z-10"> 29 23 <BackToTop /> 30 24 <div className="max-w-6xl mx-auto space-y-6"> 31 25 <header className="comic-panel p-8 text-center mb-8 bg-yellow-200"> ··· 49 43 </section> 50 44 <section className="comic-panel p-6"> 51 45 <h2 className="text-2xl font-bold mb-4">Latest Posts</h2> 52 - <PostFeed posts={allPosts.slice(0, 20)} /> 46 + <PostFeed posts={allPosts} /> 53 47 </section> 54 48 </div> 55 49 </main>
+101 -14
src/components/PostFeed.tsx
··· 1 - import React from 'react'; 1 + 'use client'; 2 + 3 + import React, {useState, useEffect, useRef, useCallback} from 'react'; 2 4 import Image from 'next/image'; 3 5 import {Post} from '@/lib/atproto'; 4 6 import {ChatBubbleOvalLeftIcon, ArrowPathIcon, HeartIcon} from '@heroicons/react/24/solid'; 5 7 8 + const BSKY_APP_URL_RAW = process.env.NEXT_PUBLIC_BSKY_APP_URL || 'https://bsky.app'; 9 + const BSKY_APP_URL = BSKY_APP_URL_RAW.startsWith('http') 10 + ? BSKY_APP_URL_RAW 11 + : `https://${BSKY_APP_URL_RAW}`; 12 + 6 13 interface PostFeedProps { 7 14 posts: Post[]; 8 15 } ··· 17 24 uri: string; 18 25 title?: string; 19 26 description?: string; 20 - thumb?: string; 27 + thumb?: string | {ref: {toString: () => string}}; 21 28 } 22 29 23 30 interface EmbedView { ··· 35 42 return parts[parts.length - 1]; 36 43 } 37 44 38 - export function PostFeed({posts}: PostFeedProps) { 45 + export function PostFeed({posts: initialPosts}: PostFeedProps) { 46 + const [posts, setPosts] = useState<Post[]>(initialPosts); 47 + const [visibleCount, setVisibleCount] = useState(20); 48 + const [isLoading, setIsLoading] = useState(false); 49 + const [hasMore, setHasMore] = useState(true); 50 + const loadMoreRef = useRef<HTMLDivElement>(null); 51 + 52 + const fetchMorePosts = useCallback(async () => { 53 + if (isLoading || !hasMore) return; 54 + 55 + setIsLoading(true); 56 + try { 57 + const response = await fetch(`/api/posts?offset=${posts.length}&limit=50`); 58 + const data = await response.json(); 59 + 60 + if (data.posts && data.posts.length > 0) { 61 + setPosts(prev => [...prev, ...data.posts]); 62 + setHasMore(data.hasMore); 63 + console.log( 64 + `Loaded ${data.posts.length} more posts. Total: ${posts.length + data.posts.length}/${data.total}` 65 + ); 66 + } else { 67 + setHasMore(false); 68 + } 69 + } catch (error) { 70 + console.error('Error fetching more posts:', error); 71 + setHasMore(false); 72 + } finally { 73 + setIsLoading(false); 74 + } 75 + }, [isLoading, hasMore, posts.length]); 76 + 77 + useEffect(() => { 78 + const observer = new IntersectionObserver( 79 + entries => { 80 + if (entries[0].isIntersecting && visibleCount < posts.length) { 81 + setVisibleCount(prev => Math.min(prev + 20, posts.length)); 82 + } else if (entries[0].isIntersecting && visibleCount >= posts.length && hasMore) { 83 + fetchMorePosts(); 84 + } 85 + }, 86 + {threshold: 0.1} 87 + ); 88 + 89 + if (loadMoreRef.current) { 90 + observer.observe(loadMoreRef.current); 91 + } 92 + 93 + return () => observer.disconnect(); 94 + }, [visibleCount, posts.length, hasMore, fetchMorePosts]); 95 + 96 + const visiblePosts = posts.slice(0, visibleCount); 39 97 if (posts.length === 0) { 40 98 return ( 41 99 <div className="text-center py-10 border-4 border-dashed border-black rounded-lg bg-yellow-50"> ··· 47 105 48 106 return ( 49 107 <div className="space-y-6"> 50 - {posts.map((post, index) => { 108 + {visiblePosts.map((post, index) => { 51 109 const panelColors = [ 52 110 'bg-gradient-to-br from-red-50 to-red-100', 53 111 'bg-gradient-to-br from-blue-50 to-blue-100', ··· 59 117 const isInvalidHandle = 60 118 post.author.handle.endsWith('.invalid') || post.author.handle === post.author.did; 61 119 const profileIdentifier = isInvalidHandle ? post.author.did : post.author.handle; 62 - const postUrl = `https://bsky.app/profile/${profileIdentifier}/post/${rkey}`; 120 + const postUrl = `${BSKY_APP_URL}/profile/${profileIdentifier}/post/${rkey}`; 63 121 const avatarInitial = isInvalidHandle ? '⚠️' : post.author.handle[0]?.toUpperCase() || '?'; 64 122 65 123 const embed = post.record.embed as EmbedView | undefined; ··· 121 179 ) { 122 180 const ext = embed.external; 123 181 if (ext) { 182 + let thumbUrl: string | undefined = undefined; 183 + if (typeof ext.thumb === 'string') { 184 + thumbUrl = ext.thumb; 185 + } else if (ext.thumb && (ext.thumb as {ref: {toString: () => string}}).ref) { 186 + const cid = (ext.thumb as {ref: {toString: () => string}}).ref.toString(); 187 + thumbUrl = `https://cdn.bsky.app/img/feed_thumbnail/plain/${post.author.did}/${cid}@jpeg`; 188 + } 189 + 124 190 externalLink = { 125 191 url: ext.uri, 126 192 title: ext.title, 127 193 description: ext.description, 128 - thumb: ext.thumb || (ext.thumb?.startsWith('http') ? ext.thumb : undefined), 194 + thumb: thumbUrl, 129 195 }; 130 196 } 131 197 } ··· 186 252 ) { 187 253 const ext = quotedEmbed.external; 188 254 if (ext) { 255 + let thumbUrl: string | undefined = undefined; 256 + if (typeof ext.thumb === 'string') { 257 + thumbUrl = ext.thumb; 258 + } else if (ext.thumb && (ext.thumb as {ref: {toString: () => string}}).ref) { 259 + const did = post.quotedPost?.author?.did || post.author.did; 260 + const cid = (ext.thumb as {ref: {toString: () => string}}).ref.toString(); 261 + thumbUrl = `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${cid}@jpeg`; 262 + } 263 + 189 264 quotedExternalLink = { 190 265 url: ext.uri, 191 266 title: ext.title, 192 267 description: ext.description, 193 - thumb: ext.thumb, 268 + thumb: thumbUrl, 194 269 }; 195 270 } 196 271 } ··· 200 275 <div key={post.uri} className={`post-card ${panelColors[index % panelColors.length]}`}> 201 276 <div className="flex items-start space-x-4"> 202 277 <a 203 - href={`https://bsky.app/profile/${profileIdentifier}`} 278 + href={`${BSKY_APP_URL}/profile/${profileIdentifier}`} 204 279 target="_blank" 205 280 rel="noopener noreferrer" 206 281 className="flex-shrink-0 hover:opacity-80 transition-opacity" ··· 230 305 <div className="flex items-center justify-between"> 231 306 <div> 232 307 <a 233 - href={`https://bsky.app/profile/${profileIdentifier}`} 308 + href={`${BSKY_APP_URL}/profile/${profileIdentifier}`} 234 309 target="_blank" 235 310 rel="noopener noreferrer" 236 311 className="text-lg font-bold text-black hover:underline inline-flex items-center gap-1" ··· 279 354 <ChatBubbleOvalLeftIcon className="w-4 h-4" /> 280 355 <span>Replying to</span> 281 356 <a 282 - href={`https://bsky.app/profile/${post.replyingToHandle}`} 357 + href={`${BSKY_APP_URL}/profile/${post.replyingToHandle}`} 283 358 target="_blank" 284 359 rel="noopener noreferrer" 285 360 className="text-blue-600 hover:underline font-medium" ··· 398 473 </div> 399 474 )} 400 475 <a 401 - href={`https://bsky.app/profile/${post.quotedPost.author.handle}`} 476 + href={`${BSKY_APP_URL}/profile/${post.quotedPost.author.handle}`} 402 477 target="_blank" 403 478 rel="noopener noreferrer" 404 479 className="text-sm font-bold text-black hover:underline inline-flex items-center gap-1" ··· 511 586 <div className="flex items-center gap-4 text-sm"> 512 587 {post.replyCount !== undefined && ( 513 588 <a 514 - href={`https://bsky.app/profile/${post.author.handle}/post/${post.uri.split('/').pop()}`} 589 + href={`${BSKY_APP_URL}/profile/${post.author.handle}/post/${post.uri.split('/').pop()}`} 515 590 target="_blank" 516 591 rel="noopener noreferrer" 517 592 className="flex items-center gap-1 px-3 py-1 bg-blue-200 border-2 border-black rounded-full font-bold hover:bg-blue-300 transition-colors" ··· 522 597 )} 523 598 {post.repostCount !== undefined && ( 524 599 <a 525 - href={`https://bsky.app/profile/${post.author.handle}/post/${post.uri.split('/').pop()}`} 600 + href={`${BSKY_APP_URL}/profile/${post.author.handle}/post/${post.uri.split('/').pop()}`} 526 601 target="_blank" 527 602 rel="noopener noreferrer" 528 603 className="flex items-center gap-1 px-3 py-1 bg-green-200 border-2 border-black rounded-full font-bold hover:bg-green-300 transition-colors" ··· 533 608 )} 534 609 {post.likeCount !== undefined && ( 535 610 <a 536 - href={`https://bsky.app/profile/${post.author.handle}/post/${post.uri.split('/').pop()}`} 611 + href={`${BSKY_APP_URL}/profile/${post.author.handle}/post/${post.uri.split('/').pop()}`} 537 612 target="_blank" 538 613 rel="noopener noreferrer" 539 614 className="flex items-center gap-1 px-3 py-1 bg-red-200 border-2 border-black rounded-full font-bold hover:bg-red-300 transition-colors" ··· 548 623 </div> 549 624 ); 550 625 })} 626 + {(visibleCount < posts.length || (hasMore && !isLoading)) && ( 627 + <div ref={loadMoreRef} className="text-center py-4"> 628 + <p className="text-zinc-600"> 629 + {isLoading ? 'Fetching more posts...' : 'Loading more posts...'} 630 + </p> 631 + </div> 632 + )} 633 + {!hasMore && posts.length > 0 && ( 634 + <div className="text-center py-4"> 635 + <p className="text-zinc-600">No more posts to load</p> 636 + </div> 637 + )} 551 638 </div> 552 639 ); 553 640 }
+6 -2
src/components/UserList.tsx
··· 31 31 <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"> 32 32 {displayedUsers.map((user, index) => { 33 33 const isInvalidHandle = user.handle.endsWith('.invalid') || user.handle === user.did; 34 + const bskyAppUrlRaw = process.env.NEXT_PUBLIC_BSKY_APP_URL || 'https://bsky.app'; 35 + const bskyAppUrl = bskyAppUrlRaw.startsWith('http') 36 + ? bskyAppUrlRaw 37 + : `https://${bskyAppUrlRaw}`; 34 38 const profileUrl = isInvalidHandle 35 - ? `https://bsky.app/profile/${user.did}` 36 - : `https://bsky.app/profile/${user.handle}`; 39 + ? `${bskyAppUrl}/profile/${user.did}` 40 + : `${bskyAppUrl}/profile/${user.handle}`; 37 41 const avatarInitial = isInvalidHandle ? '⚠️' : user.handle[0]?.toUpperCase() || '?'; 38 42 39 43 return (
+88 -10
src/lib/atproto.ts
··· 3 3 export const PDS_URL = process.env.NEXT_PUBLIC_PDS_URL || 'https://bsky.social'; 4 4 const PDS_PASSWORD = process.env.PDS_PASSWORD; 5 5 6 - export const agent = new BskyAgent({ 6 + const agent = new BskyAgent({ 7 7 service: PDS_URL, 8 8 }); 9 9 ··· 173 173 } 174 174 } 175 175 176 - export async function getUsers(limit = 10): Promise<UserProfile[]> { 176 + export async function getUsers(limit?: number): Promise<UserProfile[]> { 177 177 try { 178 178 const {data: repoData} = await agent.api.com.atproto.sync.listRepos( 179 - {limit}, 179 + {limit: limit}, 180 180 {headers: getAuthHeaders()} 181 181 ); 182 182 ··· 220 220 } 221 221 } 222 222 223 - export async function getLatestPosts(user: UserProfile, limit = 3): Promise<Post[]> { 223 + function serialize(obj: unknown): unknown { 224 + if (obj === null || typeof obj !== 'object') { 225 + return obj; 226 + } 227 + 228 + if (Array.isArray(obj)) { 229 + return obj.map(serialize); 230 + } 231 + 232 + if (obj instanceof Date) { 233 + return obj.toISOString(); 234 + } 235 + 236 + if ( 237 + typeof (obj as {toString: () => string}).toString === 'function' && 238 + (obj as {toString: () => string}).toString !== Object.prototype.toString && 239 + !(obj as {constructor?: {name?: string}}).constructor?.name?.endsWith('Object') 240 + ) { 241 + return (obj as {toString: () => string}).toString(); 242 + } 243 + 244 + const newObj: Record<string, unknown> = {}; 245 + for (const key in obj) { 246 + if (Object.prototype.hasOwnProperty.call(obj, key)) { 247 + const val = (obj as Record<string, unknown>)[key]; 248 + if ( 249 + key === 'ref' && 250 + val && 251 + typeof val === 'object' && 252 + (val as {toString: () => string}).toString 253 + ) { 254 + newObj[key] = (val as {toString: () => string}).toString(); 255 + } else { 256 + newObj[key] = serialize(val); 257 + } 258 + } 259 + } 260 + return newObj; 261 + } 262 + 263 + export async function getLatestPosts( 264 + user: UserProfile, 265 + limit = 3, 266 + cursor?: string 267 + ): Promise<{posts: Post[]; cursor?: string}> { 224 268 try { 225 269 try { 226 - const fetchLimit = 20; 227 270 const {data} = await agent.api.com.atproto.repo.listRecords( 228 271 { 229 272 repo: user.did, 230 273 collection: 'app.bsky.feed.post', 231 - limit: fetchLimit, 274 + limit: Math.min(limit, 100), 275 + cursor: cursor, 232 276 }, 233 277 {headers: getAuthHeaders()} 234 278 ); ··· 328 372 if (posts.length > 0) { 329 373 try { 330 374 const uris = posts.map(p => p.uri); 331 - const {data: postsData} = await publicAgent.app.bsky.feed.getPosts({uris}); 375 + const chunkedUris = []; 376 + for (let i = 0; i < uris.length; i += 25) { 377 + chunkedUris.push(uris.slice(i, i + 25)); 378 + } 379 + 380 + const postsViews: { 381 + uri: string; 382 + likeCount?: number; 383 + repostCount?: number; 384 + replyCount?: number; 385 + embed?: unknown; 386 + }[] = []; 387 + for (const chunk of chunkedUris) { 388 + const {data} = await publicAgent.app.bsky.feed.getPosts({uris: chunk}); 389 + postsViews.push(...data.posts); 390 + } 332 391 333 392 posts.forEach(post => { 334 - const view = postsData.posts.find(v => v.uri === post.uri); 393 + const view = postsViews.find(v => v.uri === post.uri); 335 394 if (view) { 336 395 post.likeCount = view.likeCount; 337 396 post.repostCount = view.repostCount; ··· 346 405 } 347 406 } 348 407 349 - return posts; 408 + return {posts: serialize(posts) as Post[], cursor: data.cursor}; 350 409 } catch (localError: unknown) { 351 410 if ( 352 411 typeof localError === 'object' && ··· 487 546 }) 488 547 ); 489 548 490 - return posts; 549 + return {posts: serialize(posts) as Post[], cursor: feedData.cursor}; 491 550 } 492 551 throw localError; 493 552 } ··· 495 554 } 496 555 } catch (error) { 497 556 console.error(`Error fetching posts for ${user.did}:`, error); 557 + return {posts: []}; 558 + } 559 + } 560 + 561 + export async function getAllLatestPosts(limit = 100): Promise<Post[]> { 562 + try { 563 + const users = await getUsers(); 564 + const postsPromises = users.map(user => getLatestPosts(user, 999)); 565 + const postsArrays = await Promise.all(postsPromises); 566 + 567 + const allPosts: Post[] = postsArrays 568 + .flatMap(p => p.posts) 569 + .sort((a, b) => { 570 + return new Date(b.record.createdAt).getTime() - new Date(a.record.createdAt).getTime(); 571 + }); 572 + 573 + return serialize(allPosts.slice(0, limit)) as Post[]; 574 + } catch (error) { 575 + console.error('Error fetching all latest posts:', error); 498 576 return []; 499 577 } 500 578 }