Scrapboard.org client

feat: feeds

TurtlePaw e7e776ac 2559e853

+119 -48
+25 -8
src/app/page.tsx
··· 3 3 import { Feed } from "@/components/Feed"; 4 4 import { useFetchTimeline } from "@/lib/hooks/useTimeline"; 5 5 import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; 6 - import { useRef, useEffect } from "react"; 6 + import { useRef, useEffect, useState } from "react"; 7 7 import { useFeedStore } from "@/lib/stores/feeds"; 8 8 import { useFeeds } from "@/lib/hooks/useFeeds"; 9 9 import { LoaderCircle } from "lucide-react"; ··· 18 18 const { feeds } = useFeedDefsStore(); 19 19 const { session } = useAuth(); 20 20 const sentinelRef = useRef<HTMLDivElement>(null); 21 + const [feed, setFeed] = useState<"timeline" | string>("timeline"); 21 22 22 23 useEffect(() => { 23 24 const observer = new IntersectionObserver( 24 25 (entries) => { 25 26 if (entries[0].isIntersecting) { 26 - fetchFeed(); 27 + fetchFeed(feed); 27 28 } 28 29 }, 29 30 { rootMargin: "200px" } ··· 58 59 <main className="px-5"> 59 60 <Tabs defaultValue="timeline" className="w-full"> 60 61 <TabsList 61 - className="flex w-full overflow-x-auto whitespace-nowrap no-scrollbar px-4 space-x-4" 62 + className="overflow-x-auto w-full justify-start" //"flex w-full overflow-x-auto whitespace-nowrap no-scrollbar pl-10 pr-4 space-x-4" 62 63 style={{ justifyItems: "unset" }} 63 64 > 64 - <TabsTrigger value="timeline" className="shrink-0 ml-10"> 65 + <TabsTrigger 66 + onClick={() => setFeed("timeline")} 67 + value="timeline" 68 + className="shrink-0" 69 + > 65 70 Timeline 66 71 </TabsTrigger> 67 72 {Object.entries(feeds).map(([value, it]) => ( 68 - <TabsTrigger key={value} value={value} className="shrink-0"> 73 + <TabsTrigger 74 + onClick={() => setFeed(value)} 75 + key={value} 76 + value={value} 77 + className="shrink-0" 78 + > 69 79 {it?.displayName} 70 80 </TabsTrigger> 71 81 ))} ··· 78 88 /> 79 89 </TabsContent> 80 90 81 - {Object.entries(feeds).map(([value]) => ( 82 - <TabsContent key={value} value={value}></TabsContent> 83 - ))} 91 + {Object.entries(feeds) 92 + .filter((it) => feedStore.customFeeds?.[it[0]] != null) 93 + .map(([value]) => ( 94 + <TabsContent key={value} value={value}> 95 + <Feed 96 + feed={feedStore.customFeeds[value].posts} 97 + isLoading={feedStore.customFeeds[value].isLoading} 98 + /> 99 + </TabsContent> 100 + ))} 84 101 </Tabs> 85 102 86 103 <div ref={sentinelRef} className="h-1" />
+82 -40
src/lib/hooks/useTimeline.tsx
··· 1 1 // lib/hooks/useFetchTimeline.ts 2 - import { useEffect, useRef, useCallback } from "react"; 3 - import { AppBskyEmbedImages } from "@atproto/api"; 2 + import { useEffect, useRef, useCallback, Ref } from "react"; 3 + import { AppBskyEmbedImages, AtUri } from "@atproto/api"; 4 4 import { useAuth } from "@/lib/useAuth"; 5 5 import { useFeedStore } from "../stores/feeds"; 6 + import { FeedViewPost } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 7 + 8 + function filterPosts(posts: FeedViewPost[], seenImageUrls: Set<string>) { 9 + return posts.filter((it) => { 10 + if (it.reason?.$type === "app.bsky.feed.defs#reasonRepost") return false; 11 + if ( 12 + !( 13 + AppBskyEmbedImages.isMain(it.post.embed) || 14 + AppBskyEmbedImages.isView(it.post.embed) 15 + ) 16 + ) 17 + return false; 18 + 19 + const images = (it.post.embed as AppBskyEmbedImages.View)?.images || []; 20 + const hasNew = images.some((img) => !seenImageUrls.has(img.fullsize)); 21 + if (!hasNew) return false; 22 + 23 + images.forEach((img) => seenImageUrls.add(img.fullsize)); 24 + return true; 25 + }); 26 + } 6 27 7 28 export function useFetchTimeline() { 8 29 const { agent } = useAuth(); 9 - const { timeline, setTimeline, appendTimeline, setTimelineLoading } = 10 - useFeedStore(); 30 + const { 31 + timeline, 32 + setTimeline, 33 + appendTimeline, 34 + setTimelineLoading, 35 + setCustomFeed, 36 + setCustomFeedLoading, 37 + } = useFeedStore(); 11 38 const seenImageUrls = useRef<Set<string>>(new Set()); 12 39 13 - const fetchFeed = useCallback(async () => { 14 - if (!agent || timeline.isLoading) return; 15 - setTimelineLoading(true); 16 - try { 17 - const response = await agent.getTimeline({ 18 - cursor: timeline.cursor, 19 - limit: 100, 20 - }); 21 - if (!response.success) throw new Error("Failed to fetch timeline"); 22 - 23 - const newCursor = response.data.cursor; 40 + const fetchFeed = useCallback( 41 + async (feed?: string | undefined) => { 42 + console.log("loading", feed); 43 + if (!agent || timeline.isLoading) return; 44 + if (feed) { 45 + setCustomFeedLoading(feed, true); 46 + } else setTimelineLoading(true); 47 + try { 48 + if (feed && feed != "timeline") { 49 + const response = feed.includes("list") 50 + ? await agent.app.bsky.feed.getListFeed({ 51 + cursor: timeline.cursor, 52 + limit: 100, 53 + list: feed, 54 + }) 55 + : await agent.app.bsky.feed.getFeed({ 56 + cursor: timeline.cursor, 57 + limit: 100, 58 + feed: feed, 59 + }); 24 60 25 - const filtered = response.data.feed.filter((it) => { 26 - if (it.reason?.$type === "app.bsky.feed.defs#reasonRepost") 27 - return false; 28 - if ( 29 - !( 30 - AppBskyEmbedImages.isMain(it.post.embed) || 31 - AppBskyEmbedImages.isView(it.post.embed) 32 - ) 33 - ) 34 - return false; 61 + if (!response.success) throw new Error("Failed to fetch timeline"); 62 + setCustomFeed( 63 + feed, 64 + filterPosts(response.data.feed, seenImageUrls.current).map( 65 + (it) => it.post 66 + ), 67 + response.data.cursor 68 + ); 69 + } else { 70 + const response = await agent.getTimeline({ 71 + cursor: timeline.cursor, 72 + limit: 100, 73 + }); 74 + if (!response.success) throw new Error("Failed to fetch timeline"); 35 75 36 - const images = (it.post.embed as AppBskyEmbedImages.View)?.images || []; 37 - const hasNew = images.some( 38 - (img) => !seenImageUrls.current.has(img.fullsize) 39 - ); 40 - if (!hasNew) return false; 76 + const newCursor = response.data.cursor; 41 77 42 - images.forEach((img) => seenImageUrls.current.add(img.fullsize)); 43 - return true; 44 - }); 78 + const filtered = filterPosts( 79 + response.data.feed, 80 + seenImageUrls.current 81 + ); 45 82 46 - appendTimeline(filtered, newCursor); 47 - } catch (err) { 48 - console.error("Fetch failed", err); 49 - } finally { 50 - setTimelineLoading(false); 51 - } 52 - }, [agent, timeline]); 83 + appendTimeline(filtered, newCursor); 84 + } 85 + } catch (err) { 86 + console.error("Fetch failed", err); 87 + } finally { 88 + if (feed) { 89 + setCustomFeedLoading(feed, false); 90 + } else setTimelineLoading(false); 91 + } 92 + }, 93 + [agent, timeline] 94 + ); 53 95 54 96 useEffect(() => { 55 97 const loadMinimum = async () => {
+12
src/lib/useAuth.tsx
··· 66 66 try { 67 67 const result = await c.init(); 68 68 if (result?.session) { 69 + console.log("session found", result); 70 + localStorage.setItem("did", result.session.did); 69 71 const ag = new Agent(result.session); 70 72 setSession(result.session); 71 73 setAgent(ag); 74 + } else { 75 + const did = localStorage.getItem("did"); 76 + 77 + console.log("restoring", did); 78 + if (did != null) { 79 + const result = await c.restore(did); 80 + const ag = new Agent(result); 81 + setSession(result); 82 + setAgent(ag); 83 + } 72 84 } 73 85 } catch (err) { 74 86 console.error("OAuth init failed", err);