Live video on the AT Protocol
1import { AppBskyGraphFollow } from "@atproto/api";
2import { useEffect, useState } from "react";
3import { useStreamplaceStore } from "./streamplace-store";
4import { usePDSAgent } from "./xrpc";
5
6export function useCreateFollowRecord() {
7 let agent = usePDSAgent();
8 const [isLoading, setIsLoading] = useState(false);
9
10 const createFollow = async (subjectDID: string) => {
11 if (!agent) {
12 throw new Error("No PDS agent found");
13 }
14
15 if (!agent.did) {
16 throw new Error("No user DID found, assuming not logged in");
17 }
18
19 setIsLoading(true);
20 try {
21 const record: AppBskyGraphFollow.Record = {
22 $type: "app.bsky.graph.follow",
23 subject: subjectDID,
24 createdAt: new Date().toISOString(),
25 };
26 const result = await agent.com.atproto.repo.createRecord({
27 repo: agent.did,
28 collection: "app.bsky.graph.follow",
29 record,
30 });
31 return result;
32 } finally {
33 setIsLoading(false);
34 }
35 };
36
37 return { createFollow, isLoading };
38}
39
40export function useDeleteFollowRecord() {
41 let agent = usePDSAgent();
42 const [isLoading, setIsLoading] = useState(false);
43
44 const deleteFollow = async (followRecordUri: string) => {
45 if (!agent) {
46 throw new Error("No PDS agent found");
47 }
48
49 if (!agent.did) {
50 throw new Error("No user DID found, assuming not logged in");
51 }
52
53 setIsLoading(true);
54 try {
55 const result = await agent.com.atproto.repo.deleteRecord({
56 repo: agent.did,
57 collection: "app.bsky.graph.follow",
58 rkey: followRecordUri.split("/").pop()!,
59 });
60 return result;
61 } finally {
62 setIsLoading(false);
63 }
64 };
65
66 return { deleteFollow, isLoading };
67}
68
69interface GraphManagerState {
70 isFollowing: boolean | null;
71 followUri: string | null;
72 isLoading: boolean;
73 error: string | null;
74}
75
76interface GraphManagerActions {
77 follow: () => Promise<void>;
78 unfollow: () => Promise<void>;
79 refresh: () => Promise<void>;
80}
81
82export function useGraphManager(
83 subjectDID: string | null | undefined,
84): GraphManagerState & GraphManagerActions {
85 const agent = usePDSAgent();
86 const [isFollowing, setIsFollowing] = useState<boolean | null>(null);
87 const [followUri, setFollowUri] = useState<string | null>(null);
88 const [isLoading, setIsLoading] = useState(false);
89 const [error, setError] = useState<string | null>(null);
90
91 const userDID = agent?.did;
92
93 const streamplaceUrl = useStreamplaceStore((state) => state.url);
94
95 const fetchFollowStatus = async () => {
96 if (!userDID || !subjectDID || !streamplaceUrl) {
97 setIsFollowing(null);
98 setFollowUri(null);
99 return;
100 }
101
102 setIsLoading(true);
103 setError(null);
104 try {
105 const res = await fetch(
106 `${streamplaceUrl}/xrpc/place.stream.graph.getFollowingUser?subjectDID=${encodeURIComponent(subjectDID)}&userDID=${encodeURIComponent(userDID)}`,
107 {
108 credentials: "include",
109 },
110 );
111
112 if (!res.ok) {
113 const errorText = await res.text();
114 throw new Error(`Failed to fetch follow status: ${errorText}`);
115 }
116
117 const data = await res.json();
118
119 if (data.follow) {
120 setIsFollowing(true);
121 setFollowUri(data.follow.uri);
122 } else {
123 setIsFollowing(false);
124 setFollowUri(null);
125 }
126 } catch (err) {
127 setError(
128 `Could not determine follow state: ${err instanceof Error ? err.message : `Unknown error: ${err}`}`,
129 );
130 setIsFollowing(null);
131 } finally {
132 setIsLoading(false);
133 }
134 };
135
136 useEffect(() => {
137 if (!userDID || !subjectDID) {
138 setIsFollowing(null);
139 setFollowUri(null);
140 setError(null);
141 return;
142 }
143
144 fetchFollowStatus();
145 }, [userDID, subjectDID, streamplaceUrl]);
146
147 const follow = async () => {
148 if (!agent || !subjectDID) {
149 throw new Error("Cannot follow: not logged in or no subject DID");
150 }
151
152 if (!agent.did) {
153 throw new Error("No user DID found, assuming not logged in");
154 }
155
156 setIsLoading(true);
157 setError(null);
158 const previousState = isFollowing;
159 setIsFollowing(true); // Optimistic
160
161 try {
162 const record: AppBskyGraphFollow.Record = {
163 $type: "app.bsky.graph.follow",
164 subject: subjectDID,
165 createdAt: new Date().toISOString(),
166 };
167 const result = await agent.com.atproto.repo.createRecord({
168 repo: agent.did,
169 collection: "app.bsky.graph.follow",
170 record,
171 });
172 setFollowUri(result.data.uri);
173 setIsFollowing(true);
174 } catch (err) {
175 setIsFollowing(previousState);
176 const errorMsg = `Failed to follow: ${err instanceof Error ? err.message : "Unknown error"}`;
177 setError(errorMsg);
178 throw new Error(errorMsg);
179 } finally {
180 setIsLoading(false);
181 }
182 };
183
184 const unfollow = async () => {
185 if (!agent || !subjectDID) {
186 throw new Error("Cannot unfollow: not logged in or no subject DID");
187 }
188
189 if (!agent.did) {
190 throw new Error("No user DID found, assuming not logged in");
191 }
192
193 if (!followUri) {
194 throw new Error("Cannot unfollow: no follow URI found");
195 }
196
197 setIsLoading(true);
198 setError(null);
199 const previousState = isFollowing;
200 const previousUri = followUri;
201 setIsFollowing(false); // Optimistic
202 setFollowUri(null);
203
204 try {
205 await agent.com.atproto.repo.deleteRecord({
206 repo: agent.did,
207 collection: "app.bsky.graph.follow",
208 rkey: followUri.split("/").pop()!,
209 });
210 setIsFollowing(false);
211 setFollowUri(null);
212 } catch (err) {
213 setIsFollowing(previousState);
214 setFollowUri(previousUri);
215 const errorMsg = `Failed to unfollow: ${err instanceof Error ? err.message : "Unknown error"}`;
216 setError(errorMsg);
217 throw new Error(errorMsg);
218 } finally {
219 setIsLoading(false);
220 }
221 };
222
223 return {
224 isFollowing,
225 followUri,
226 isLoading,
227 error,
228 follow,
229 unfollow,
230 refresh: fetchFollowStatus,
231 };
232}