A stream.place VOD client inspired by icarly.com
Something went wrong. Try again.
7.1 kB · 244 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
interface Post { uri: string; cid: string; author: { did: string; handle: string; displayName?: string; avatar?: string; }; record: { text: string; createdAt: string; reply?: unknown; }; replyCount?: number; repostCount?: number; likeCount?: number; indexedAt: string; isRepost?: boolean; isThread?: boolean;}
interface FeedCursor { handle: string; cursor?: string;}
export default function BlogPosts() { const [posts, setPosts] = useState<Post[]>([]); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [cursors, setCursors] = useState<FeedCursor[]>([]); const observerRef = useRef<IntersectionObserver | null>(null); const loadMoreRef = useRef<HTMLDivElement>(null); const seenUris = useRef<Set<string>>(new Set());
const handles = [ 'stream1.atmosphereconf.org', 'stream2.atmosphereconf.org', 'stream3.atmosphereconf.org', 'iame.li', 'stream.place' ];
const fetchMorePosts = async () => { if (loadingMore) return; setLoadingMore(true); try { const newPosts: Post[] = []; const newCursors: FeedCursor[] = [];
for (const cursorData of cursors.length > 0 ? cursors : handles.map(h => ({ handle: h, cursor: undefined }))) { const cursorParam = cursorData.cursor ? `&cursor=${cursorData.cursor}` : ''; const response = await fetch(`https://api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=${cursorData.handle}&limit=20${cursorParam}`); if (!response.ok) { newCursors.push(cursorData); continue; } const data = await response.json(); for (const item of data.feed) { if (seenUris.current.has(item.post.uri)) { continue; } if (item.reply && item.reply.parent?.author?.did !== item.post.author.did) { continue; }
seenUris.current.add(item.post.uri); const post: Post = { uri: item.post.uri, cid: item.post.cid, author: item.post.author, record: item.post.record, replyCount: item.post.replyCount, repostCount: item.post.repostCount, likeCount: item.post.likeCount, indexedAt: item.post.indexedAt, isRepost: item.reason?.$type === 'app.bsky.feed.defs#reasonRepost', isThread: item.reply && item.reply.parent?.author?.did === item.post.author.did, };
newPosts.push(post); }
// Save cursor for next fetch if (data.cursor) { newCursors.push({ handle: cursorData.handle, cursor: data.cursor }); } }
newPosts.sort((a, b) => new Date(b.indexedAt).getTime() - new Date(a.indexedAt).getTime()); setPosts(prev => [...prev, ...newPosts]); setCursors(newCursors); } catch (error) { console.error('Failed to fetch posts:', error); } finally { setLoadingMore(false); setLoading(false); } };
useEffect(() => { fetchMorePosts(); }, []);
// Infinite scroll observer const handleObserver = useCallback((entries: IntersectionObserverEntry[]) => { const target = entries[0]; if (target.isIntersecting && !loadingMore && cursors.length > 0) { fetchMorePosts(); } }, [loadingMore, cursors]);
useEffect(() => { const option = { root: null, rootMargin: '200px', threshold: 0 };
observerRef.current = new IntersectionObserver(handleObserver, option); if (loadMoreRef.current) { observerRef.current.observe(loadMoreRef.current); }
return () => { if (observerRef.current) { observerRef.current.disconnect(); } }; }, [handleObserver]);
// Convert Bluesky URI to web URL const getPostUrl = (uri: string) => { // uri format: at://did:plc:xxx/app.bsky.feed.post/xxxxx const parts = uri.replace('at://', '').split('/'); const did = parts[0]; const postId = parts[2]; return `https://bsky.app/profile/${did}/post/${postId}`; };
if (loading) { return ( <div style={{ textAlign: 'center', padding: '50px', color: '#fff', fontSize: '20px' }}> Loading iBlogs... </div> ); }
return ( <div className="blog-posts-container"> {posts.map((post) => ( <a key={post.uri} href={getPostUrl(post.uri)} target="_blank" rel="noopener noreferrer" className="blog-post" style={{ textDecoration: 'none', color: 'inherit', cursor: 'pointer', display: 'block' }} > <div className="blog-post-header"> <div className="profile-pic-weird"> {post.author.avatar ? ( <img src={post.author.avatar} alt={post.author.handle} /> ) : ( <div style={{ width: '100%', height: '100%', background: '#A6CC3A', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '24px', fontWeight: 'bold', color: '#62166F' }}> {post.author.handle[0].toUpperCase()} </div> )} </div> <div> <div className="blog-post-author"> {post.author.displayName || post.author.handle} </div> <div className="blog-post-date"> {new Date(post.record.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit', })} </div> </div> </div>
{post.isRepost && ( <div className="repost-badge">↻ REPOST</div> )}
{post.isThread && ( <div className="thread-indicator">🧵 THREAD</div> )}
<div className="blog-post-content" dangerouslySetInnerHTML={{ __html: post.record.text .replace(/\n/g, '<br />') .replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>') }} /> </a> ))} {/* Loading trigger for infinite scroll */} {cursors.length > 0 && ( <div ref={loadMoreRef} style={{ height: '50px', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: '16px', fontWeight: 'bold' }}> {loadingMore ? 'Loading more posts...' : 'Scroll for more...'} </div> )} </div> );}