Live video on the AT Protocol
79
fork

Configure Feed

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

app: [wip] add follow on livestream

+589 -27
+155
js/app/components/follow-button.tsx
··· 1 + import React, { useEffect, useState } from "react"; 2 + import { useAppDispatch, useAppSelector } from "../store/hooks"; 3 + import { selectStreamplace } from "../features/streamplace/streamplaceSlice"; 4 + import { followUser, unfollowUser } from "../features/bluesky/blueskySlice"; 5 + import { Button, View, Text } from "tamagui"; 6 + import { Plus, Check } from "@tamagui/lucide-icons"; 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 + */ 16 + interface FollowButtonProps { 17 + streamerDID: string; 18 + currentUserDID?: string; 19 + onFollowChange?: (isFollowing: boolean) => void; 20 + } 21 + 22 + const 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 [followRKey, setFollowRKey] = 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 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}/api/following/${currentUserDID}`, 47 + { 48 + credentials: "include", 49 + headers: { 50 + "X-User-DID": currentUserDID || "", 51 + }, 52 + }, 53 + ); 54 + 55 + if (!res.ok) { 56 + const errorText = await res.text(); 57 + throw new Error(`Failed to fetch following list: ${errorText}`); 58 + } 59 + 60 + const data = await res.json(); 61 + if (cancelled) return; 62 + 63 + const following = Array.isArray(data) ? data : []; 64 + const followRecord = following.find( 65 + (f: any) => f.SubjectDID === streamerDID, 66 + ); 67 + 68 + if (followRecord) { 69 + setIsFollowing(true); 70 + setFollowRKey(followRecord.RKey); 71 + } else { 72 + setIsFollowing(false); 73 + setFollowRKey(null); 74 + } 75 + } catch (err) { 76 + if (!cancelled) setError("Could not determine follow state"); 77 + } 78 + }; 79 + 80 + fetchFollowStatus(); 81 + return () => { 82 + cancelled = true; 83 + }; 84 + }, [currentUserDID, streamerDID, streamplaceUrl]); 85 + 86 + const handleFollow = async () => { 87 + setError(null); 88 + setIsFollowing(true); // Optimistic 89 + try { 90 + const result = await dispatch(followUser(streamerDID)).unwrap(); 91 + setIsFollowing(true); 92 + setFollowRKey(result.rkey); 93 + onFollowChange?.(true); 94 + } catch (err) { 95 + setIsFollowing(false); 96 + setError( 97 + `Failed to follow: ${err instanceof Error ? err.message : "Unknown error"}`, 98 + ); 99 + } 100 + }; 101 + 102 + const handleUnfollow = async () => { 103 + if (!followRKey) { 104 + setError("Cannot unfollow: missing record key"); 105 + return; 106 + } 107 + 108 + setError(null); 109 + setIsFollowing(false); // Optimistic 110 + try { 111 + await dispatch( 112 + unfollowUser({ subjectDID: streamerDID, rkey: followRKey }), 113 + ).unwrap(); 114 + setIsFollowing(false); 115 + setFollowRKey(null); 116 + onFollowChange?.(false); 117 + } catch (err) { 118 + setIsFollowing(true); 119 + setError( 120 + `Failed to unfollow: ${err instanceof Error ? err.message : "Unknown error"}`, 121 + ); 122 + } 123 + }; 124 + 125 + return ( 126 + <View flexDirection="row" alignItems="center" gap={8}> 127 + {isFollowing ? ( 128 + <Button 129 + backgroundColor="transparent" 130 + onPress={handleUnfollow} 131 + aria-label="Following" 132 + icon={Check} 133 + > 134 + Following 135 + </Button> 136 + ) : ( 137 + <Button 138 + backgroundColor="transparent" 139 + onPress={handleFollow} 140 + aria-label="Follow" 141 + icon={Plus} 142 + > 143 + Follow 144 + </Button> 145 + )} 146 + {error && ( 147 + <Text color="#c00" marginLeft={8}> 148 + {error} 149 + </Text> 150 + )} 151 + </View> 152 + ); 153 + }; 154 + 155 + export default FollowButton;
+171 -27
js/app/components/livestream/livestream.tsx
··· 14 14 import { useKeyboard } from "hooks/useKeyboard"; 15 15 import usePlatform from "hooks/usePlatform"; 16 16 import { useCallback, useEffect, useState } from "react"; 17 - import { LayoutChangeEvent, View as RNView, SafeAreaView } from "react-native"; 17 + import { 18 + LayoutChangeEvent, 19 + View as RNView, 20 + SafeAreaView, 21 + Linking, 22 + } from "react-native"; 18 23 import { useAppDispatch, useAppSelector } from "store/hooks"; 19 - import { Button, H2, H3, Text, useWindowDimensions, View } from "tamagui"; 24 + import { 25 + Button, 26 + H2, 27 + H3, 28 + isWeb, 29 + Text, 30 + useWindowDimensions, 31 + View, 32 + } from "tamagui"; 20 33 import { MessageCircleOff, MessageCircleMore } from "@tamagui/lucide-icons"; 34 + import FollowButton from "components/follow-button"; 35 + import { useToastController } from "@tamagui/toast"; 36 + import { selectProfiles, getProfile } from "features/bluesky/blueskySlice"; 37 + import storage from "storage"; 21 38 22 39 export default function Livestream(props: Partial<PlayerProps>) { 23 40 return ( ··· 30 47 export function LivestreamInner(props: Partial<PlayerProps>) { 31 48 const telemetry = useAppSelector(selectTelemetry); 32 49 const player = useAppSelector(usePlayer()); 50 + const profiles = useAppSelector(selectProfiles); 51 + const toast = useToastController(); 33 52 34 53 const { src, ...extraProps } = props; 35 54 const dispatch = useAppDispatch(); ··· 38 57 const [videoWidth, setVideoWidth] = useState(0); 39 58 const [videoHeight, setVideoHeight] = useState(0); 40 59 const { isKeyboardVisible, keyboardHeight } = useKeyboard(); 41 - const { isIOS } = usePlatform(); 60 + const { isIOS, isWeb } = usePlatform(); 42 61 43 62 const [outerHeight, setOuterHeight] = useState(0); 44 63 const [innerHeight, setInnerHeight] = useState(0); 45 64 const [isChatVisible, setIsChatVisible] = useState(true); 65 + const [currentUserDID, setCurrentUserDID] = useState<string | null>(null); 66 + 67 + const streamerDID = player.livestream?.author?.did; 68 + const streamerProfile = streamerDID ? profiles[streamerDID] : undefined; 69 + const streamerHandle = streamerProfile?.handle || streamerDID; 46 70 47 71 // this would all be really easy if i had library that would give me the 48 72 // safe area view height and width but i don't. so let's measure ··· 64 88 } 65 89 }, [video, width, height]); 66 90 91 + useEffect(() => { 92 + getCurrentUserDID().then((did) => { 93 + console.log("currentUserDID:", did); 94 + setCurrentUserDID(did); 95 + }); 96 + }, []); 97 + 98 + useEffect(() => { 99 + if (streamerDID && !streamerProfile) { 100 + dispatch(getProfile(streamerDID)); 101 + } 102 + }, [streamerDID, streamerProfile, dispatch]); 103 + 67 104 let slideKeyboard = 0; 68 105 if (isIOS && keyboardHeight > 0) { 69 106 slideKeyboard = -keyboardHeight + (outerHeight - innerHeight); 70 107 } 71 108 109 + const handleFollowChange = (isFollowing: boolean) => { 110 + if (!streamerHandle) return; 111 + if (isFollowing) { 112 + toast.show(`You are now following @${streamerHandle}`); 113 + } else { 114 + toast.show(`You have unfollowed @${streamerHandle}`); 115 + } 116 + }; 117 + 72 118 return ( 73 119 <RNView style={{ flex: 1 }}> 74 120 <SafeAreaView style={{ flex: 1 }} onLayout={onOuterLayout}> ··· 156 202 fg={0} 157 203 p="$4" 158 204 display="none" 159 - flexDirection="row" 160 - alignItems="flex-start" 161 - justifyContent="space-between" 205 + flexDirection="column" 162 206 $gtXs={{ display: "flex" }} 163 207 > 164 - <H2>{player.livestream?.record.title}</H2> 165 208 <View 166 209 flexDirection="row" 167 210 alignItems="center" 168 - gap="$2" 169 - paddingRight="$3" 211 + justifyContent="space-between" 212 + width="100%" 170 213 > 171 - <Viewers viewers={player.viewers ?? 0} /> 172 - <Button 173 - backgroundColor="transparent" 174 - onPress={() => setIsChatVisible(!isChatVisible)} 175 - marginLeft="$2" 214 + <View 215 + flexDirection="row" 216 + alignItems="center" 217 + gap="$2" 218 + minWidth={0} 176 219 > 177 - {isChatVisible ? ( 178 - <MessageCircleOff size={22} /> 179 - ) : ( 180 - <MessageCircleMore size={22} /> 220 + {streamerHandle && ( 221 + <Text 222 + onPress={() => 223 + Linking.openURL( 224 + `https://bsky.app/profile/${streamerHandle}`, 225 + ) 226 + } 227 + aria-label={`View @${streamerHandle} on Bluesky`} 228 + style={{ cursor: "pointer" }} 229 + > 230 + {`@${streamerHandle}`} 231 + </Text> 232 + )} 233 + {streamerDID && currentUserDID && ( 234 + <FollowButton 235 + streamerDID={streamerDID} 236 + currentUserDID={currentUserDID} 237 + onFollowChange={handleFollowChange} 238 + /> 181 239 )} 182 - </Button> 240 + </View> 241 + <View flexDirection="row" alignItems="center" gap="$2"> 242 + <Viewers viewers={player.viewers ?? 0} /> 243 + <Button 244 + backgroundColor="transparent" 245 + onPress={() => setIsChatVisible(!isChatVisible)} 246 + marginLeft="$2" 247 + > 248 + {isChatVisible ? ( 249 + <MessageCircleOff size={22} /> 250 + ) : ( 251 + <MessageCircleMore size={22} /> 252 + )} 253 + </Button> 254 + </View> 255 + </View> 256 + <View width="100%" marginTop={4}> 257 + <H2 258 + maxWidth="100%" 259 + lineHeight={32} 260 + numberOfLines={ 261 + typeof window !== "undefined" && window.innerWidth < 600 262 + ? 1 263 + : undefined 264 + } 265 + > 266 + {player.livestream?.record.title} 267 + </H2> 183 268 </View> 184 269 </View> 185 270 </View> ··· 208 293 : undefined 209 294 } 210 295 > 296 + {/* Native potrait view: first row = handle/follow/viewers, second row = title */} 211 297 <View 212 298 $gtXs={{ display: "none" }} 213 - flexDirection="row" 214 - gap="$2" 299 + flexDirection="column" 215 300 borderBottomColor="#666" 216 301 borderBottomWidth={1} 217 302 borderTopColor="#666" 218 303 borderTopWidth={1} 219 304 zIndex={1} 220 305 > 221 - <View f={1} fb={0} padding="$3" justifyContent="center"> 222 - <Text fontSize={18} numberOfLines={1} ellipsizeMode="tail"> 223 - {player.livestream?.record.title} 224 - </Text> 225 - </View> 226 306 <View 227 307 flexDirection="row" 228 308 alignItems="center" 229 309 gap="$2" 310 + paddingTop="$1" 311 + paddingBottom="$1" 312 + paddingLeft="$3" 230 313 paddingRight="$3" 314 + justifyContent="space-between" 231 315 > 232 - <Viewers viewers={player.viewers ?? 0} /> 316 + <View 317 + style={{ 318 + flexDirection: "row", 319 + alignItems: "center", 320 + flex: 1, 321 + gap: 8, 322 + minWidth: 0, 323 + }} 324 + > 325 + {streamerHandle && ( 326 + <Text 327 + onPress={() => 328 + Linking.openURL( 329 + `https://bsky.app/profile/${streamerHandle}`, 330 + ) 331 + } 332 + aria-label={`View @${streamerHandle} on Bluesky`} 333 + style={{ cursor: "pointer" }} 334 + numberOfLines={1} 335 + ellipsizeMode="tail" 336 + > 337 + {`@${streamerHandle}`} 338 + </Text> 339 + )} 340 + {streamerDID && currentUserDID && ( 341 + <FollowButton 342 + streamerDID={streamerDID} 343 + currentUserDID={currentUserDID} 344 + onFollowChange={handleFollowChange} 345 + /> 346 + )} 347 + </View> 348 + <View style={{ alignItems: "flex-end" }}> 349 + <Viewers viewers={player.viewers ?? 0} /> 350 + </View> 351 + </View> 352 + <View 353 + paddingLeft="$3" 354 + paddingRight="$3" 355 + paddingBottom="$3" 356 + marginTop={-15} 357 + > 358 + <Text fontSize={18} numberOfLines={1} ellipsizeMode="tail"> 359 + {player.livestream?.record.title} 360 + </Text> 233 361 </View> 234 362 </View> 235 363 <Chat ··· 249 377 </RNView> 250 378 ); 251 379 } 380 + 381 + async function getCurrentUserDID(): Promise<string | null> { 382 + try { 383 + const did = await storage.getItem( 384 + `${isWeb ? "@@atproto/oauth-client-browser(sub)" : "did"}`, 385 + ); 386 + if (did) { 387 + return did; 388 + } 389 + console.debug("Could not find user DID"); 390 + return null; 391 + } catch (err) { 392 + console.error("[ERROR] Failed to get current user DID:", err); 393 + return null; 394 + } 395 + }
+97
js/app/features/bluesky/blueskySlice.tsx
··· 2 2 Agent, 3 3 AppBskyFeedPost, 4 4 AppBskyGraphBlock, 5 + AppBskyGraphFollow, 5 6 BlobRef, 6 7 RichText, 7 8 } from "@atproto/api"; ··· 89 90 params.delete("code"); 90 91 u.search = params.toString(); 91 92 window.history.replaceState(null, "", u.toString()); 93 + }; 94 + 95 + // Deterministic rkey for follow 96 + const createDeterministicRKey = ( 97 + userDID: string, 98 + subjectDID: string, 99 + ): string => { 100 + const combinedStr = userDID + ":" + subjectDID; 101 + let hash = 0; 102 + for (let i = 0; i < combinedStr.length; i++) { 103 + const char = combinedStr.charCodeAt(i); 104 + hash = (hash << 5) - hash + char; 105 + hash = hash & hash; // Convert to 32bit integer 106 + } 107 + // Convert to hex string and take first 16 chars 108 + const hexHash = Math.abs(hash).toString(16).padStart(8, "0"); 109 + return hexHash.substring(0, 16); 92 110 }; 93 111 94 112 export const blueskySlice = createAppSlice({ ··· 927 945 }, 928 946 }, 929 947 ), 948 + 949 + followUser: create.asyncThunk( 950 + async (subjectDID: string, thunkAPI) => { 951 + const { bluesky } = thunkAPI.getState() as { 952 + bluesky: BlueskyState; 953 + }; 954 + if (!bluesky.pdsAgent) { 955 + throw new Error("No agent"); 956 + } 957 + const did = bluesky.oauthSession?.did; 958 + if (!did) { 959 + throw new Error("No DID"); 960 + } 961 + 962 + const rkey = createDeterministicRKey(did, subjectDID); 963 + const record: AppBskyGraphFollow.Record = { 964 + subject: subjectDID, 965 + createdAt: new Date().toISOString(), 966 + }; 967 + await bluesky.pdsAgent.com.atproto.repo.createRecord({ 968 + repo: did, 969 + collection: "app.bsky.graph.follow", 970 + rkey: rkey, 971 + record, 972 + }); 973 + 974 + return { subjectDID, rkey }; 975 + }, 976 + { 977 + pending: (state) => { 978 + console.log("followUser pending"); 979 + }, 980 + fulfilled: (state, action) => { 981 + console.log("followUser fulfilled", action.payload); 982 + }, 983 + rejected: (state, action) => { 984 + console.error("followUser rejected", action.error); 985 + }, 986 + }, 987 + ), 988 + 989 + unfollowUser: create.asyncThunk( 990 + async ( 991 + { subjectDID, rkey }: { subjectDID: string; rkey: string }, 992 + thunkAPI, 993 + ) => { 994 + const { bluesky } = thunkAPI.getState() as { 995 + bluesky: BlueskyState; 996 + }; 997 + if (!bluesky.pdsAgent) { 998 + throw new Error("No agent"); 999 + } 1000 + const did = bluesky.oauthSession?.did; 1001 + if (!did) { 1002 + throw new Error("No DID"); 1003 + } 1004 + 1005 + await bluesky.pdsAgent.com.atproto.repo.deleteRecord({ 1006 + repo: did, 1007 + collection: "app.bsky.graph.follow", 1008 + rkey: rkey, 1009 + }); 1010 + 1011 + return { subjectDID }; 1012 + }, 1013 + { 1014 + pending: (state) => { 1015 + console.log("unfollowUser pending"); 1016 + }, 1017 + fulfilled: (state, action) => { 1018 + console.log("unfollowUser fulfilled", action.payload); 1019 + }, 1020 + rejected: (state, action) => { 1021 + console.error("unfollowUser rejected", action.error); 1022 + }, 1023 + }, 1024 + ), 930 1025 }), 931 1026 932 1027 // You can define your selectors here. These selectors receive the slice ··· 980 1075 chatPost, 981 1076 chatMessage, 982 1077 createBlockRecord, 1078 + followUser, 1079 + unfollowUser, 983 1080 } = blueskySlice.actions; 984 1081 985 1082 // Selectors returned by `slice.selectors` take the root state as their first argument.
+3
pkg/api/api.go
··· 146 146 } 147 147 apiRouter.GET("/api/live-users", a.HandleLiveUsers(ctx)) 148 148 apiRouter.GET("/api/view-count/:user", a.HandleViewCount(ctx)) 149 + apiRouter.GET("/api/following/:user", a.HandleFollowing(ctx)) 150 + apiRouter.POST("/api/follow/:user", a.HandleFollow(ctx)) 151 + apiRouter.DELETE("/api/follow/:user", a.HandleUnfollow(ctx)) 149 152 apiRouter.NotFound = a.HandleAPI404(ctx) 150 153 router.Handler("GET", "/api/*resource", apiRouter) 151 154 router.Handler("POST", "/api/*resource", apiRouter)
+163
pkg/api/api_internal.go
··· 3 3 import ( 4 4 "bufio" 5 5 "context" 6 + "crypto/sha256" 6 7 "encoding/base64" 8 + "encoding/hex" 7 9 "encoding/json" 8 10 "fmt" 9 11 "io" ··· 19 21 "strings" 20 22 "time" 21 23 24 + "github.com/bluesky-social/indigo/api/bsky" 22 25 "github.com/julienschmidt/httprouter" 23 26 "github.com/prometheus/client_golang/prometheus/promhttp" 24 27 sloghttp "github.com/samber/slog-http" ··· 32 35 "stream.place/streamplace/pkg/renditions" 33 36 v0 "stream.place/streamplace/pkg/schema/v0" 34 37 ) 38 + 39 + // Get current user DID from custom header 40 + func getUserDIDFromHeader(r *http.Request) (string, error) { 41 + didHeader := r.Header.Get("X-User-DID") 42 + if didHeader != "" && strings.HasPrefix(didHeader, "did:") { 43 + return didHeader, nil 44 + } 45 + 46 + return "", fmt.Errorf("could not find user DID in headers") 47 + } 48 + 49 + // Deterministic rkey for a follow relationship 50 + func deterministicRKey(userDID, subjectDID string) string { 51 + h := sha256.New() 52 + h.Write([]byte(userDID + ":" + subjectDID)) 53 + return hex.EncodeToString(h.Sum(nil))[:16] // 16 chars for brevity 54 + } 55 + 56 + // GET /api/following/:user 57 + func (a *StreamplaceAPI) HandleFollowing(ctx context.Context) httprouter.Handle { 58 + return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { 59 + user := p.ByName("user") 60 + if user == "" { 61 + log.Error(ctx, "Missing user parameter") 62 + errors.WriteHTTPBadRequest(w, "user required", nil) 63 + return 64 + } 65 + user, err := a.NormalizeUser(ctx, user) 66 + if err != nil { 67 + log.Error(ctx, "Failed to normalize user", "user", user, "error", err) 68 + errors.WriteHTTPNotFound(w, "user not found", err) 69 + return 70 + } 71 + 72 + following, err := a.Model.GetUserFollowing(ctx, user) 73 + if err != nil { 74 + log.Error(ctx, "Failed to get user following", "user", user, "error", err) 75 + errors.WriteHTTPInternalServerError(w, "unable to get following", err) 76 + return 77 + } 78 + 79 + bs, err := json.Marshal(following) 80 + if err != nil { 81 + log.Error(ctx, "Failed to marshal following list", "error", err) 82 + errors.WriteHTTPInternalServerError(w, "unable to marshal json", err) 83 + return 84 + } 85 + w.Write(bs) 86 + } 87 + } 88 + 89 + // POST /api/follow/:user 90 + func (a *StreamplaceAPI) HandleFollow(ctx context.Context) httprouter.Handle { 91 + return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { 92 + userDID, err := getUserDIDFromHeader(r) 93 + if err != nil { 94 + log.Error(ctx, "Failed to get user DID from session", "error", err) 95 + http.Error(w, fmt.Sprintf("Unauthorized: %v", err), http.StatusUnauthorized) 96 + return 97 + } 98 + 99 + subject := p.ByName("user") 100 + if subject == "" { 101 + http.Error(w, "user required", http.StatusBadRequest) 102 + return 103 + } 104 + subject, err = a.NormalizeUser(ctx, subject) 105 + if err != nil { 106 + log.Error(ctx, "Invalid user", "subject", subject, "error", err) 107 + http.Error(w, "invalid user", http.StatusBadRequest) 108 + return 109 + } 110 + 111 + if userDID == subject { 112 + http.Error(w, "cannot follow yourself", http.StatusBadRequest) 113 + return 114 + } 115 + 116 + // Check if already following 117 + following, err := a.Model.GetUserFollowing(ctx, userDID) 118 + if err != nil { 119 + log.Error(ctx, "Error getting user following", "userDID", userDID, "error", err) 120 + } else { 121 + for _, f := range following { 122 + if f.SubjectDID == subject { 123 + log.Debug(ctx, "User already following subject", "userDID", userDID, "subject", subject) 124 + w.WriteHeader(http.StatusNoContent) 125 + return 126 + } 127 + } 128 + } 129 + 130 + rkey := deterministicRKey(userDID, subject) 131 + createdAt := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") 132 + follow := &bsky.GraphFollow{ 133 + Subject: subject, 134 + CreatedAt: createdAt, 135 + } 136 + 137 + err = a.Model.CreateFollow(ctx, userDID, rkey, follow) 138 + if err != nil { 139 + log.Error(ctx, "Failed to create follow in local database", "userDID", userDID, "subject", subject, "error", err) 140 + http.Error(w, fmt.Sprintf("failed to follow: %v", err), http.StatusInternalServerError) 141 + return 142 + } 143 + } 144 + } 145 + 146 + // DELETE /api/follow/:user 147 + func (a *StreamplaceAPI) HandleUnfollow(ctx context.Context) httprouter.Handle { 148 + return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { 149 + userDID, err := getUserDIDFromHeader(r) 150 + if err != nil { 151 + log.Error(ctx, "Failed to get user DID from session", "error", err) 152 + http.Error(w, fmt.Sprintf("Unauthorized: %v", err), http.StatusUnauthorized) 153 + return 154 + } 155 + 156 + subject := p.ByName("user") 157 + if subject == "" { 158 + http.Error(w, "user required", http.StatusBadRequest) 159 + return 160 + } 161 + subject, err = a.NormalizeUser(ctx, subject) 162 + if err != nil { 163 + log.Error(ctx, "Invalid user", "subject", subject, "error", err) 164 + http.Error(w, "invalid user", http.StatusBadRequest) 165 + return 166 + } 167 + 168 + // Find the follow record to get the rkey 169 + following, err := a.Model.GetUserFollowing(ctx, userDID) 170 + if err != nil { 171 + log.Error(ctx, "Error getting user following", "userDID", userDID, "error", err) 172 + http.Error(w, fmt.Sprintf("failed to query follows: %v", err), http.StatusInternalServerError) 173 + return 174 + } 175 + 176 + var rkey string 177 + for _, f := range following { 178 + if f.SubjectDID == subject { 179 + rkey = f.RKey 180 + break 181 + } 182 + } 183 + 184 + if rkey == "" { 185 + log.Debug(ctx, "No follow relationship found to delete", "userDID", userDID, "subject", subject) 186 + w.WriteHeader(http.StatusNoContent) 187 + return 188 + } 189 + 190 + err = a.Model.DeleteFollow(ctx, userDID, rkey) 191 + if err != nil { 192 + log.Error(ctx, "Failed to delete follow from local database", "userDID", userDID, "subject", subject, "rkey", rkey, "error", err) 193 + http.Error(w, fmt.Sprintf("failed to unfollow: %v", err), http.StatusInternalServerError) 194 + return 195 + } 196 + } 197 + } 35 198 36 199 func (a *StreamplaceAPI) ServeInternalHTTP(ctx context.Context) error { 37 200 handler, err := a.InternalHandler(ctx)