Live video on the AT Protocol
79
fork

Configure Feed

Select the types of activity you want to include in your feed.

at natb/spinner-while-loading-chat 149 lines 4.3 kB view raw
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 &nbsp; 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;