an appview-less Bluesky client using Constellation and PDS Queries reddwarf.app
frontend spa bluesky reddwarf microcosm
1import { AtUri, type Agent } from "@atproto/api"; 2import { useQueryConstellation, type linksRecordsResponse } from "./useQuery"; 3import type { QueryClient } from "@tanstack/react-query"; 4import { TID } from "@atproto/common-web"; 5 6export 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 37export 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}