Live video on the AT Protocol
1import { Check, Plus } from "@tamagui/lucide-icons";
2import React, { useEffect, useState } from "react";
3import { Button, Text, View } from "tamagui";
4import { followUser, unfollowUser } from "../features/bluesky/blueskySlice";
5import { selectStreamplace } from "../features/streamplace/streamplaceSlice";
6import { useAppDispatch, useAppSelector } 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 { url: streamplaceUrl } = useAppSelector(selectStreamplace);
31 const dispatch = useAppDispatch();
32
33 // Hide button if not logged in or viewing own stream
34 if (!currentUserDID || currentUserDID === streamerDID) return null;
35
36 // Fetch initial follow state using xrpc
37 useEffect(() => {
38 let cancelled = false;
39
40 const fetchFollowStatus = async () => {
41 if (!currentUserDID || !streamerDID) return;
42
43 setError(null);
44 try {
45 const res = await fetch(
46 `${streamplaceUrl}/xrpc/place.stream.graph.getFollowingUser?subjectDID=${encodeURIComponent(streamerDID)}&userDID=${encodeURIComponent(currentUserDID)}`,
47 {
48 credentials: "include",
49 },
50 );
51
52 if (!res.ok) {
53 const errorText = await res.text();
54 throw new Error(`Failed to fetch follow status: ${errorText}`);
55 }
56
57 const data = await res.json();
58 if (cancelled) return;
59
60 if (data.follow) {
61 setIsFollowing(true);
62 setFollowUri(data.follow.uri);
63 } else {
64 setIsFollowing(false);
65 setFollowUri(null);
66 }
67 } catch (err) {
68 if (!cancelled) setError("Could not determine follow state");
69 }
70 };
71
72 fetchFollowStatus();
73 return () => {
74 cancelled = true;
75 };
76 }, [currentUserDID, streamerDID, streamplaceUrl]);
77
78 const handleFollow = async () => {
79 setError(null);
80 setIsFollowing(true); // Optimistic
81 try {
82 await dispatch(followUser(streamerDID)).unwrap();
83 setIsFollowing(true);
84 onFollowChange?.(true);
85 } catch (err) {
86 setIsFollowing(false);
87 setError(
88 `Failed to follow: ${err instanceof Error ? err.message : "Unknown error"}`,
89 );
90 }
91 };
92
93 const handleUnfollow = async () => {
94 setError(null);
95 setIsFollowing(false); // Optimistic
96 try {
97 await dispatch(
98 unfollowUser({
99 subjectDID: streamerDID,
100 ...(followUri ? { followUri } : {}),
101 }),
102 ).unwrap();
103 setIsFollowing(false);
104 setFollowUri(null);
105 onFollowChange?.(false);
106 } catch (err) {
107 setIsFollowing(true);
108 setError(
109 `Failed to unfollow: ${err instanceof Error ? err.message : "Unknown error"}`,
110 );
111 }
112 };
113
114 return (
115 <View flexDirection="row" alignItems="center" gap={8}>
116 {isFollowing === null ? (
117 // Skeleton loader to prevent layout shift
118 <Button backgroundColor="transparent" disabled>
119
120 </Button>
121 ) : isFollowing ? (
122 <Button
123 backgroundColor="transparent"
124 onPress={handleUnfollow}
125 aria-label="Following"
126 icon={Check}
127 >
128 Following
129 </Button>
130 ) : (
131 <Button
132 backgroundColor="transparent"
133 onPress={handleFollow}
134 aria-label="Follow"
135 icon={Plus}
136 >
137 Follow
138 </Button>
139 )}
140 {error && (
141 <Text color="#c00" marginLeft={8}>
142 {error}
143 </Text>
144 )}
145 </View>
146 );
147};
148
149export default FollowButton;