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