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}