an independent Bluesky client using Constellation, PDS Queries, and other services reddwarf.app
frontend spa bluesky reddwarf microcosm client app

Feeds page

+145 -3
+145 -3
src/routes/feeds.tsx
··· 1 - import { createFileRoute } from "@tanstack/react-router"; 1 + import * as ATPAPI from "@atproto/api"; 2 + import { createFileRoute, Link } from "@tanstack/react-router"; 3 + import { useAtom } from "jotai"; 4 + import * as React from "react"; 2 5 3 6 import { Header } from "~/components/Header"; 7 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 8 + import { imgCDNAtom, quickAuthAtom } from "~/utils/atoms"; 9 + import { 10 + useQueryArbitrary, 11 + useQueryIdentity, 12 + useQueryPreferences, 13 + } from "~/utils/useQuery"; 4 14 5 15 export const Route = createFileRoute("/feeds")({ 6 16 component: Feeds, 7 17 }); 8 18 9 19 export function Feeds() { 20 + const { agent, status } = useAuth(); 21 + const [quickAuth] = useAtom(quickAuthAtom); 22 + const isAuthRestoring = quickAuth ? status === "loading" : false; 23 + 24 + const identityresultmaybe = useQueryIdentity( 25 + !isAuthRestoring ? agent?.did : undefined, 26 + ); 27 + const identity = identityresultmaybe?.data; 28 + 29 + const prefsresultmaybe = useQueryPreferences({ 30 + agent: !isAuthRestoring ? (agent ?? undefined) : undefined, 31 + pdsUrl: !isAuthRestoring ? identity?.pds : undefined, 32 + }); 33 + const prefs = prefsresultmaybe?.data; 34 + 35 + const savedFeeds = React.useMemo(() => { 36 + const savedFeedsPref = prefs?.preferences?.find( 37 + (p: any) => p?.$type === "app.bsky.actor.defs#savedFeedsPrefV2", 38 + ); 39 + return savedFeedsPref?.items || []; 40 + }, [prefs]); 41 + 42 + const pinnedFeeds = React.useMemo(() => { 43 + return savedFeeds.filter((feed: any) => feed.pinned); 44 + }, [savedFeeds]); 45 + 46 + const nonPinnedFeeds = React.useMemo(() => { 47 + return savedFeeds.filter((feed: any) => !feed.pinned); 48 + }, [savedFeeds]); 49 + 10 50 return ( 11 51 <div className=""> 12 52 <Header ··· 18 58 window.location.assign("/"); 19 59 } 20 60 }} 21 - bottomBorderDisabled={true} 61 + bottomBorderDisabled={false} 22 62 /> 23 - Feeds page (coming soon) 63 + <div className="py-4"> 64 + {pinnedFeeds.length > 0 && ( 65 + <div className="mb-6"> 66 + <h2 className="text-lg font-semibold mb-3 px-4">Pinned Feeds</h2> 67 + <div className="flex flex-col"> 68 + {pinnedFeeds.map((feed: any) => ( 69 + <FeedItem key={feed.value} feedUri={feed.value} /> 70 + ))} 71 + </div> 72 + </div> 73 + )} 74 + 75 + {nonPinnedFeeds.length > 0 && ( 76 + <div> 77 + <h2 className="text-lg font-semibold mb-3 px-4">Saved Feeds</h2> 78 + <div className="flex flex-col"> 79 + {nonPinnedFeeds.map((feed: any) => ( 80 + <FeedItem key={feed.value} feedUri={feed.value} /> 81 + ))} 82 + </div> 83 + </div> 84 + )} 85 + 86 + {savedFeeds.length === 0 && ( 87 + <div className="text-center text-gray-500 py-8 px-4"> 88 + <p>No feeds saved yet.</p> 89 + <p className="mt-2"> 90 + Save feeds from the home page to see them here. 91 + </p> 92 + </div> 93 + )} 94 + </div> 24 95 </div> 25 96 ); 26 97 } 98 + 99 + function FeedItem({ feedUri }: { feedUri: string }) { 100 + const { data: feedData } = useQueryArbitrary(feedUri); 101 + const feed = feedData?.value as ATPAPI.AppBskyFeedGenerator.Record; 102 + const [imgcdn] = useAtom(imgCDNAtom); 103 + let aturi: ATPAPI.AtUri | null = null; 104 + try { 105 + aturi = new ATPAPI.AtUri(feedUri); 106 + } catch (err) { 107 + // todo terrible hack lmaoo (hack type: forcing following feed to fallback to rinds fresh feed) 108 + aturi = new ATPAPI.AtUri("at://did:plc:mn45tewwnse5btfftvd3powc/app.bsky.feed.generator/rinds"); 109 + } 110 + 111 + function getAvatarUrl() { 112 + const link = feed?.avatar?.ref?.["$link"]; 113 + if (!link) return null; 114 + return `https://${imgcdn}/img/avatar/plain/${aturi?.host}/${link}@jpeg`; 115 + } 116 + 117 + const avatarUrl = getAvatarUrl(); 118 + 119 + return ( 120 + <Link 121 + className="p-4 border-t border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-900 cursor-pointer transition-colors" 122 + to="/profile/$did/feed/$rkey" 123 + params={{ did: aturi?.host, rkey: aturi?.rkey }} 124 + onClick={(e) => { 125 + e.stopPropagation(); 126 + }} 127 + //disabled={feedUri === "following"} 128 + > 129 + <div className="flex items-center justify-between"> 130 + <div className="flex gap-3"> 131 + <img 132 + src={avatarUrl || "/defaultpfp.png"} 133 + alt={feed?.displayName || "Feed avatar"} 134 + className="w-10 h-10 rounded-sm object-cover" 135 + onError={(e) => { 136 + const target = e.target as HTMLImageElement; 137 + target.onerror = null; 138 + target.src = "/defaultpfp.png"; 139 + }} 140 + /> 141 + <div> 142 + <h3 className="font-medium text-gray-900 dark:text-gray-100"> 143 + {feed?.displayName || feedUri.split("/").pop()} 144 + </h3> 145 + <p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-1"> 146 + {feedUri === "following" ? "(not implemented, if clicked will open an alternative)" : feed?.description || "No description"} 147 + </p> 148 + </div> 149 + </div> 150 + <div className="text-gray-400"> 151 + <svg 152 + xmlns="http://www.w3.org/2000/svg" 153 + width="24" 154 + height="24" 155 + viewBox="0 0 24 24" 156 + fill="none" 157 + stroke="currentColor" 158 + strokeWidth="2" 159 + strokeLinecap="round" 160 + strokeLinejoin="round" 161 + > 162 + <path d="M9 18l6-6-6-6"></path> 163 + </svg> 164 + </div> 165 + </div> 166 + </Link> 167 + ); 168 + }