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

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;