Live video on the AT Protocol
1import { Button, Icon, Text, zero } from "@streamplace/components";
2import { Plus } from "lucide-react-native";
3import React, { useEffect, useState } from "react";
4import { View } from "react-native";
5import { followUser, unfollowUser } from "../features/bluesky/blueskySlice";
6import { selectStreamplace } from "../features/streamplace/streamplaceSlice";
7import { useAppDispatch, useAppSelector } from "../store/hooks";
8
9/**
10 * FollowButton component for following/unfollowing a streamer.
11 *
12 * Props:
13 * - streamerDID: string — The DID of the streamer to follow/unfollow
14 * - currentUserDID?: string — The DID of the current user (optional)
15 * - onFollowChange?: (isFollowing: boolean) => void — Optional callback when follow state changes
16 */
17interface FollowButtonProps {
18 streamerDID: string;
19 currentUserDID?: string;
20 onFollowChange?: (isFollowing: boolean) => void;
21}
22
23const FollowButton: React.FC<FollowButtonProps> = ({
24 streamerDID,
25 currentUserDID,
26 onFollowChange,
27}) => {
28 const [isFollowing, setIsFollowing] = useState<boolean | null>(null);
29 const [error, setError] = useState<string | null>(null);
30 const [followUri, setFollowUri] = useState<string | null>(null);
31 const { url: streamplaceUrl } = useAppSelector(selectStreamplace);
32 const dispatch = useAppDispatch();
33
34 // Hide button if not logged in or viewing own stream
35 if (!currentUserDID || currentUserDID === streamerDID) return null;
36
37 // Fetch initial follow state using xrpc
38 useEffect(() => {
39 let cancelled = false;
40
41 const fetchFollowStatus = async () => {
42 if (!currentUserDID || !streamerDID) return;
43
44 setError(null);
45 try {
46 const res = await fetch(
47 `${streamplaceUrl}/xrpc/place.stream.graph.getFollowingUser?subjectDID=${encodeURIComponent(streamerDID)}&userDID=${encodeURIComponent(currentUserDID)}`,
48 {
49 credentials: "include",
50 },
51 );
52
53 if (!res.ok) {
54 const errorText = await res.text();
55 throw new Error(`Failed to fetch follow status: ${errorText}`);
56 }
57
58 const data = await res.json();
59 if (cancelled) return;
60
61 if (data.follow) {
62 setIsFollowing(true);
63 setFollowUri(data.follow.uri);
64 } else {
65 setIsFollowing(false);
66 setFollowUri(null);
67 }
68 } catch (err) {
69 if (!cancelled) setError("Could not determine follow state");
70 }
71 };
72
73 fetchFollowStatus();
74 return () => {
75 cancelled = true;
76 };
77 }, [currentUserDID, streamerDID, streamplaceUrl]);
78
79 const handleFollow = async () => {
80 setError(null);
81 setIsFollowing(true); // Optimistic
82 try {
83 await dispatch(followUser(streamerDID)).unwrap();
84 setIsFollowing(true);
85 onFollowChange?.(true);
86 } catch (err) {
87 setIsFollowing(false);
88 setError(
89 `Failed to follow: ${err instanceof Error ? err.message : "Unknown error"}`,
90 );
91 }
92 };
93
94 const handleUnfollow = async () => {
95 setError(null);
96 setIsFollowing(false); // Optimistic
97 try {
98 await dispatch(
99 unfollowUser({
100 subjectDID: streamerDID,
101 ...(followUri ? { followUri } : {}),
102 }),
103 ).unwrap();
104 setIsFollowing(false);
105 setFollowUri(null);
106 onFollowChange?.(false);
107 } catch (err) {
108 setIsFollowing(true);
109 setError(
110 `Failed to unfollow: ${err instanceof Error ? err.message : "Unknown error"}`,
111 );
112 }
113 };
114
115 return (
116 <View
117 style={[
118 { flexDirection: "row" },
119 { alignItems: "center" },
120 zero.gap.all[2],
121 ]}
122 >
123 <Button
124 onPress={isFollowing ? handleUnfollow : handleFollow}
125 variant={isFollowing ? "secondary" : "primary"}
126 size="pill"
127 disabled={isFollowing === null}
128 loading={isFollowing === null}
129 leftIcon={!isFollowing && <Icon icon={Plus} size="sm" />}
130 >
131 {isFollowing === null
132 ? "Loading..."
133 : isFollowing
134 ? "Unfollow"
135 : "Follow"}
136 </Button>
137 {error && <Text style={[{ color: "#c00" }, zero.ml[2]]}>{error}</Text>}
138 </View>
139 );
140};
141
142export default FollowButton;