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 { useStore } from "store";
6import { useStreamplaceUrl } from "store/hooks";
7
8/**
9 * FollowButton component for following/unfollowing a streamer.
10 *
11 * Props:
12 * - streamerDID: string — The DID of the streamer to follow/unfollow
13 * - currentUserDID?: string — The DID of the current user (optional)
14 * - onFollowChange?: (isFollowing: boolean) => void — Optional callback when follow state changes
15 */
16interface FollowButtonProps {
17 streamerDID: string;
18 currentUserDID?: string;
19 onFollowChange?: (isFollowing: boolean) => void;
20}
21
22const FollowButton: React.FC<FollowButtonProps> = ({
23 streamerDID,
24 currentUserDID,
25 onFollowChange,
26}) => {
27 const [isFollowing, setIsFollowing] = useState<boolean | null>(null);
28 const [error, setError] = useState<string | null>(null);
29 const [followUri, setFollowUri] = useState<string | null>(null);
30 const streamplaceUrl = useStreamplaceUrl();
31 const followUser = useStore((state) => state.followUser);
32 const unfollowUser = useStore((state) => state.unfollowUser);
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]);
78
79 const handleFollow = async () => {
80 setError(null);
81 setIsFollowing(true); // Optimistic
82 try {
83 await followUser(streamerDID);
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 unfollowUser(streamerDID, followUri ?? undefined);
99 setIsFollowing(false);
100 setFollowUri(null);
101 onFollowChange?.(false);
102 } catch (err) {
103 setIsFollowing(true);
104 setError(
105 `Failed to unfollow: ${err instanceof Error ? err.message : "Unknown error"}`,
106 );
107 }
108 };
109
110 return (
111 <View
112 style={[
113 { flexDirection: "row" },
114 { alignItems: "center" },
115 zero.gap.all[2],
116 ]}
117 >
118 <Button
119 onPress={isFollowing ? handleUnfollow : handleFollow}
120 variant={isFollowing ? "secondary" : "primary"}
121 size="pill"
122 width="min"
123 disabled={isFollowing === null}
124 loading={isFollowing === null}
125 leftIcon={!isFollowing && <Icon icon={Plus} size="sm" />}
126 hoverStyle={isFollowing ? { backgroundColor: "#dc2626" } : undefined}
127 >
128 {isFollowing === null
129 ? "Loading..."
130 : isFollowing
131 ? "Following"
132 : "Follow"}
133 </Button>
134 {error && <Text style={[{ color: "#c00" }, zero.ml[2]]}>{error}</Text>}
135 </View>
136 );
137};
138
139export default FollowButton;