an appview-less Bluesky client using Constellation and PDS Queries reddwarf.app
frontend spa bluesky reddwarf microcosm
1import { AtUri } from "@atproto/api"; 2import { TID } from "@atproto/common-web"; 3import { useQueryClient } from "@tanstack/react-query"; 4import { useAtom } from "jotai"; 5import React, { createContext, use, useCallback, useEffect, useRef } from "react"; 6 7import { useAuth } from "~/providers/UnifiedAuthProvider"; 8import { constellationURLAtom, internalLikedPostsAtom } from "~/utils/atoms"; 9import { constructArbitraryQuery, constructConstellationQuery, type linksRecordsResponse } from "~/utils/useQuery"; 10 11export type LikeRecord = { uri: string; target: string; cid: string }; 12export type LikeMutation = { type: 'like'; target: string; cid: string }; 13export type UnlikeMutation = { type: 'unlike'; likeRecordUri: string; target: string, originalRecord: LikeRecord }; 14export type Mutation = LikeMutation | UnlikeMutation; 15 16interface LikeMutationQueueContextType { 17 fastState: (target: string) => LikeRecord | null | undefined; 18 fastToggle: (target:string, cid:string) => void; 19 backfillState: (target: string, user: string) => Promise<void>; 20} 21 22const LikeMutationQueueContext = createContext<LikeMutationQueueContextType | undefined>(undefined); 23 24export function LikeMutationQueueProvider({ children }: { children: React.ReactNode }) { 25 const { agent } = useAuth(); 26 const queryClient = useQueryClient(); 27 const [likedPosts, setLikedPosts] = useAtom(internalLikedPostsAtom); 28 const [constellationurl] = useAtom(constellationURLAtom); 29 30 const likedPostsRef = useRef(likedPosts); 31 useEffect(() => { 32 likedPostsRef.current = likedPosts; 33 }, [likedPosts]); 34 35 const queueRef = useRef<Mutation[]>([]); 36 const runningRef = useRef(false); 37 38 const fastState = (target: string) => likedPosts[target]; 39 40 const setFastState = useCallback( 41 (target: string, record: LikeRecord | null) => 42 setLikedPosts((prev) => ({ ...prev, [target]: record })), 43 [setLikedPosts] 44 ); 45 46 const enqueue = (mutation: Mutation) => queueRef.current.push(mutation); 47 48 const fastToggle = useCallback((target: string, cid: string) => { 49 const likedRecord = likedPostsRef.current[target]; 50 51 if (likedRecord) { 52 setFastState(target, null); 53 if (likedRecord.uri !== 'pending') { 54 enqueue({ type: "unlike", likeRecordUri: likedRecord.uri, target, originalRecord: likedRecord }); 55 } 56 } else { 57 setFastState(target, { uri: "pending", target, cid }); 58 enqueue({ type: "like", target, cid }); 59 } 60 }, [setFastState]); 61 62 /** 63 * 64 * @deprecated dont use it yet, will cause infinite rerenders 65 */ 66 const backfillState = async (target: string, user: string) => { 67 const query = constructConstellationQuery({ 68 constellation: constellationurl, 69 method: "/links", 70 target, 71 collection: "app.bsky.feed.like", 72 path: ".subject.uri", 73 dids: [user], 74 }); 75 const data = await queryClient.fetchQuery(query); 76 const likes = (data as linksRecordsResponse)?.linking_records?.slice(0, 50) ?? []; 77 const found = likes.find((r) => r.did === user); 78 if (found) { 79 const uri = `at://${found.did}/${found.collection}/${found.rkey}`; 80 const ciddata = await queryClient.fetchQuery( 81 constructArbitraryQuery(uri) 82 ); 83 if (ciddata?.cid) 84 setFastState(target, { uri, target, cid: ciddata?.cid }); 85 } else { 86 setFastState(target, null); 87 } 88 }; 89 90 91 useEffect(() => { 92 if (!agent?.did) return; 93 94 const processQueue = async () => { 95 if (runningRef.current || queueRef.current.length === 0) return; 96 runningRef.current = true; 97 98 while (queueRef.current.length > 0) { 99 const mutation = queueRef.current.shift()!; 100 try { 101 if (mutation.type === "like") { 102 const newRecord = { 103 repo: agent.did!, 104 collection: "app.bsky.feed.like", 105 rkey: TID.next().toString(), 106 record: { 107 $type: "app.bsky.feed.like", 108 subject: { uri: mutation.target, cid: mutation.cid }, 109 createdAt: new Date().toISOString(), 110 }, 111 }; 112 const response = await agent.com.atproto.repo.createRecord(newRecord); 113 if (!response.success) throw new Error("createRecord failed"); 114 115 const uri = `at://${agent.did}/${newRecord.collection}/${newRecord.rkey}`; 116 setFastState(mutation.target, { 117 uri, 118 target: mutation.target, 119 cid: mutation.cid, 120 }); 121 } else if (mutation.type === "unlike") { 122 const aturi = new AtUri(mutation.likeRecordUri); 123 await agent.com.atproto.repo.deleteRecord({ repo: agent.did!, collection: aturi.collection, rkey: aturi.rkey }); 124 setFastState(mutation.target, null); 125 } 126 } catch (err) { 127 console.error("Like mutation failed, reverting:", err); 128 if (mutation.type === 'like') { 129 setFastState(mutation.target, null); 130 } else if (mutation.type === 'unlike') { 131 setFastState(mutation.target, mutation.originalRecord); 132 } 133 } 134 } 135 runningRef.current = false; 136 }; 137 138 const interval = setInterval(processQueue, 1000); 139 return () => clearInterval(interval); 140 }, [agent, setFastState]); 141 142 const value = { fastState, fastToggle, backfillState }; 143 144 return ( 145 <LikeMutationQueueContext value={value}> 146 {children} 147 </LikeMutationQueueContext> 148 ); 149} 150 151export function useLikeMutationQueue() { 152 const context = use(LikeMutationQueueContext); 153 if (context === undefined) { 154 throw new Error('useLikeMutationQueue must be used within a LikeMutationQueueProvider'); 155 } 156 return context; 157}