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

Configure Feed

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

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 + }