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

Configure Feed

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

constellation-based follow state

rimar1337 404a7649 d1602982

+210 -18
+19 -1
src/components/Login.tsx
··· 154 154 const OAuthForm = () => { 155 155 const { loginWithOAuth } = useAuth(); 156 156 const [handle, setHandle] = useState(""); 157 - const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (handle.trim()) loginWithOAuth(handle); }; 157 + 158 + useEffect(() => { 159 + const lastHandle = localStorage.getItem("lastHandle"); 160 + if (lastHandle) setHandle(lastHandle); 161 + }, []); 162 + 163 + const handleSubmit = (e: React.FormEvent) => { 164 + e.preventDefault(); 165 + if (handle.trim()) { 166 + localStorage.setItem("lastHandle", handle); 167 + loginWithOAuth(handle); 168 + } 169 + }; 158 170 return ( 159 171 <form onSubmit={handleSubmit} className="flex flex-col gap-3"> 160 172 <p className="text-xs text-gray-500 dark:text-gray-400">Sign in with AT. Your password is never shared.</p> ··· 171 183 const [serviceURL, setServiceURL] = useState("bsky.social"); 172 184 const [error, setError] = useState<string | null>(null); 173 185 186 + useEffect(() => { 187 + const lastHandle = localStorage.getItem("lastHandle"); 188 + if (lastHandle) setUser(lastHandle); 189 + }, []); 190 + 174 191 const handleSubmit = async (e: React.FormEvent) => { 175 192 e.preventDefault(); 176 193 setError(null); 177 194 try { 195 + localStorage.setItem("lastHandle", user); 178 196 await loginWithPassword(user, password, `https://${serviceURL}`); 179 197 } catch (err) { 180 198 setError("Login failed. Check your handle and App Password.");
+43 -8
src/routes/profile.$did/index.tsx
··· 7 7 useQueryIdentity, 8 8 useQueryProfile, 9 9 useInfiniteQueryAuthorFeed, 10 + useQueryConstellation, 11 + type linksRecordsResponse, 10 12 } from "~/utils/useQuery"; 13 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 14 + import { AtUri } from "@atproto/api"; 15 + import { TID } from "@atproto/common-web"; 16 + import { toggleFollow, useGetFollowState } from "~/utils/followState"; 11 17 12 18 export const Route = createFileRoute("/profile/$did/")({ 13 19 component: ProfileComponent, 14 20 }); 15 21 16 22 function ProfileComponent() { 23 + // booo bad this is not always the did it might be a handle, use identity.did instead 17 24 const { did } = Route.useParams(); 18 25 const queryClient = useQueryClient(); 19 - 26 + const { agent } = useAuth(); 20 27 const { 21 28 data: identity, 22 29 isLoading: isIdentityLoading, 23 30 error: identityError, 24 31 } = useQueryIdentity(did); 32 + 33 + const followRecords = useGetFollowState({ 34 + target: identity?.did || did, 35 + user: agent?.did, 36 + }); 25 37 26 38 const resolvedDid = did.startsWith("did:") ? did : identity?.did; 27 39 const resolvedHandle = did.startsWith("did:") ? identity?.handle : did; ··· 141 153 also delay the backfill to be on demand because it would be pretty intense 142 154 also save it persistently 143 155 */} 144 - {true ? ( 156 + {identity?.did !== agent?.did ? ( 145 157 <> 146 - <button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"> 147 - Follow 148 - </button> 149 - <button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"> 150 - Unfollow 151 - </button> 158 + {!(followRecords?.length && followRecords?.length > 0) ? ( 159 + <button 160 + onClick={() => 161 + toggleFollow({ 162 + agent: agent || undefined, 163 + targetDid: identity?.did, 164 + followRecords: followRecords, 165 + queryClient: queryClient, 166 + }) 167 + } 168 + className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]" 169 + > 170 + Follow 171 + </button> 172 + ) : ( 173 + <button 174 + onClick={() => 175 + toggleFollow({ 176 + agent: agent || undefined, 177 + targetDid: identity?.did, 178 + followRecords: followRecords, 179 + queryClient: queryClient, 180 + }) 181 + } 182 + className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]" 183 + > 184 + Unfollow 185 + </button> 186 + )} 152 187 </> 153 188 ) : ( 154 189 <button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]">
+129
src/utils/followState.ts
··· 1 + import { AtUri, type Agent } from "@atproto/api"; 2 + import { useQueryConstellation, type linksRecordsResponse } from "./useQuery"; 3 + import type { QueryClient } from "@tanstack/react-query"; 4 + import { TID } from "@atproto/common-web"; 5 + 6 + export function useGetFollowState({ 7 + target, 8 + user, 9 + }: { 10 + target: string; 11 + user?: string; 12 + }): string[] | undefined { 13 + const { data: followData } = useQueryConstellation( 14 + user 15 + ? { 16 + method: "/links", 17 + target: target, 18 + // @ts-expect-error overloading sucks so much 19 + collection: "app.bsky.graph.follow", 20 + path: ".subject", 21 + dids: [user], 22 + } 23 + : { method: "undefined", target: "whatever" } 24 + // overloading sucks so much 25 + ) as { data: linksRecordsResponse | undefined }; 26 + const follows = followData?.linking_records.slice(0, 50) ?? []; 27 + 28 + if (follows.length > 0) { 29 + return follows.map((linksRecord) => { 30 + return `at://${linksRecord.did}/${linksRecord.collection}/${linksRecord.rkey}`; 31 + }); 32 + } 33 + 34 + return undefined; 35 + } 36 + 37 + export function toggleFollow({ 38 + agent, 39 + targetDid, 40 + followRecords, 41 + queryClient, 42 + }: { 43 + agent?: Agent; 44 + targetDid?: string; 45 + followRecords: undefined | string[]; 46 + queryClient: QueryClient; 47 + }) { 48 + if (!agent?.did || !targetDid) return; 49 + 50 + const queryKey = [ 51 + "constellation", 52 + "/links", 53 + targetDid, 54 + "app.bsky.graph.follow", 55 + ".subject", 56 + undefined, 57 + [agent.did], 58 + ] as const; 59 + 60 + const updateCache = ( 61 + updater: ( 62 + oldData: linksRecordsResponse | undefined 63 + ) => linksRecordsResponse | undefined 64 + ) => { 65 + queryClient.setQueryData( 66 + queryKey, 67 + (oldData: linksRecordsResponse | undefined) => updater(oldData) 68 + ); 69 + }; 70 + 71 + if (typeof followRecords === "undefined") { 72 + const newRecord = { 73 + repo: agent.did, 74 + collection: "app.bsky.graph.follow", 75 + rkey: TID.next().toString(), 76 + record: { 77 + $type: "app.bsky.graph.follow", 78 + subject: targetDid, 79 + createdAt: new Date().toISOString(), 80 + }, 81 + }; 82 + 83 + updateCache((old) => { 84 + const newLinkingRecords = [newRecord, ...(old?.linking_records ?? [])]; 85 + return { 86 + ...old, 87 + linking_records: newLinkingRecords, 88 + } as linksRecordsResponse; 89 + }); 90 + 91 + agent.com.atproto.repo.createRecord(newRecord).catch((err) => { 92 + console.error("Follow failed, reverting cache:", err); 93 + // rollback cache 94 + updateCache((old) => { 95 + return { 96 + ...old, 97 + linking_records: 98 + old?.linking_records.filter((r) => r.rkey !== newRecord.rkey) ?? [], 99 + } as linksRecordsResponse; 100 + }); 101 + }); 102 + 103 + return; 104 + } 105 + 106 + followRecords.forEach((followRecord) => { 107 + const aturi = new AtUri(followRecord); 108 + agent.com.atproto.repo 109 + .deleteRecord({ 110 + repo: agent.did!, 111 + collection: "app.bsky.graph.follow", 112 + rkey: aturi.rkey, 113 + }) 114 + .catch(console.error); 115 + }); 116 + 117 + updateCache((old) => { 118 + if (!old?.linking_records) return old; 119 + return { 120 + ...old, 121 + linking_records: old.linking_records.filter( 122 + (rec) => 123 + !followRecords.includes( 124 + `at://${rec.did}/${rec.collection}/${rec.rkey}` 125 + ) 126 + ), 127 + }; 128 + }); 129 + }
+19 -9
src/utils/useQuery.ts
··· 187 187 | "/links/distinct-dids" 188 188 | "/links/count" 189 189 | "/links/count/distinct-dids" 190 - | "/links/all", 190 + | "/links/all" 191 + | "undefined", 191 192 target: string, 192 193 collection?: string, 193 194 path?: string, 194 - cursor?: string 195 + cursor?: string, 196 + dids?: string[] 195 197 } 196 198 ) { 197 199 // : QueryOptions< ··· 203 205 // Error 204 206 // > 205 207 return queryOptions({ 206 - queryKey: ["post", query?.method, query?.target, query?.collection, query?.path, query?.cursor] as const, 208 + queryKey: ["constellation", query?.method, query?.target, query?.collection, query?.path, query?.cursor, query?.dids] as const, 207 209 queryFn: async () => { 208 - if (!query) return undefined as undefined 210 + if (!query || query.method === "undefined") return undefined as undefined 209 211 const method = query.method 210 212 const target = query.target 211 213 const collection = query?.collection 212 214 const path = query?.path 213 215 const cursor = query.cursor 216 + const dids = query?.dids 214 217 const res = await fetch( 215 - `https://constellation.microcosm.blue${method}?target=${encodeURIComponent(target)}${collection ? `&collection=${encodeURIComponent(collection)}` : ""}${path ? `&path=${encodeURIComponent(path)}` : ""}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}` 218 + `https://constellation.microcosm.blue${method}?target=${encodeURIComponent(target)}${collection ? `&collection=${encodeURIComponent(collection)}` : ""}${path ? `&path=${encodeURIComponent(path)}` : ""}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}${dids ? dids.map((did) => `&did=${encodeURIComponent(did)}`).join("") : ""}` 216 219 ); 217 220 if (!res.ok) throw new Error("Failed to fetch post"); 218 221 try { ··· 235 238 } 236 239 }, 237 240 // enforce short lifespan 238 - staleTime: 5 * 60 * 1000, // 5 minutes 239 - gcTime: 5 * 60 * 1000, 241 + staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 242 + gcTime: /*0//*/5 * 60 * 1000, 240 243 }); 241 244 } 242 245 export function useQueryConstellation(query: { ··· 245 248 collection: string; 246 249 path: string; 247 250 cursor?: string; 251 + dids?: string[]; 248 252 }): UseQueryResult<linksRecordsResponse, Error>; 249 253 export function useQueryConstellation(query: { 250 254 method: "/links/distinct-dids"; ··· 272 276 target: string; 273 277 }): UseQueryResult<linksAllResponse, Error>; 274 278 export function useQueryConstellation(): undefined; 279 + export function useQueryConstellation(query: { 280 + method: "undefined"; 281 + target: string; 282 + }): undefined; 275 283 export function useQueryConstellation(query?: { 276 284 method: 277 285 | "/links" 278 286 | "/links/distinct-dids" 279 287 | "/links/count" 280 288 | "/links/count/distinct-dids" 281 - | "/links/all"; 289 + | "/links/all" 290 + | "undefined"; 282 291 target: string; 283 292 collection?: string; 284 293 path?: string; 285 294 cursor?: string; 295 + dids?: string[]; 286 296 }): 287 297 | UseQueryResult< 288 298 | linksRecordsResponse ··· 304 314 collection: string; 305 315 rkey: string; 306 316 }; 307 - type linksRecordsResponse = { 317 + export type linksRecordsResponse = { 308 318 total: string; 309 319 linking_records: linksRecord[]; 310 320 cursor?: string;