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 { 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}