Live video on the AT Protocol

Merge pull request #708 from streamplace/natb/offline

feat: better offline state for new player

authored by

natalie and committed by
GitHub
cc43fdb2 95ccb900

+3272 -333
+1
js/app/components/follow-button.tsx
··· 119 119 onPress={isFollowing ? handleUnfollow : handleFollow} 120 120 variant={isFollowing ? "secondary" : "primary"} 121 121 size="pill" 122 + width="min" 122 123 disabled={isFollowing === null} 123 124 loading={isFollowing === null} 124 125 leftIcon={!isFollowing && <Icon icon={Plus} size="sm" />}
+95 -120
js/app/components/mobile/desktop-ui.tsx
··· 18 18 useSharedValue, 19 19 withTiming, 20 20 } from "react-native-reanimated"; 21 + import { useSafeAreaInsets } from "react-native-safe-area-context"; 21 22 import { 22 23 BottomControlBar, 23 24 MuteOverlay, ··· 59 60 const { width, height } = usePlayerDimensions(); 60 61 const { shouldShowFloatingMetrics } = useResponsiveLayout(); 61 62 63 + const originalSafeAreaInsets = useSafeAreaInsets(); 64 + 62 65 const offline = useOffline(); 63 66 const showMetrics = usePlayerStore((state) => state.showDebugInfo); 64 67 const pipAction = usePlayerStore((state) => state.pipAction); 65 68 const videoRef = usePlayerStore((state) => state.videoRef); 66 69 const embedded = usePlayerStore((state) => state.embedded); 70 + 71 + const safeAreaInsets = embedded 72 + ? { ...originalSafeAreaInsets, top: 0 } 73 + : originalSafeAreaInsets; 67 74 68 75 const segment = useSegment(); 69 76 ··· 152 159 if (pipAction) pipAction(); 153 160 }, [pipAction]); 154 161 155 - // Live timer for offline overlay 156 - const [timeSinceLastSeen, setTimeSinceLastSeen] = useState("Unknown"); 157 - 158 - useEffect(() => { 159 - if (!offline || !segment?.startTime) { 160 - setTimeSinceLastSeen("Unknown"); 161 - return; 162 - } 163 - 164 - const updateTimer = () => { 165 - const now = new Date(); 166 - const lastSeen = new Date(segment.startTime); 167 - const diffMs = now.getTime() - lastSeen.getTime(); 168 - const diffMinutes = Math.floor(diffMs / 60000); 169 - const diffSeconds = Math.floor((diffMs % 60000) / 1000); 170 - 171 - if (diffMinutes > 0) { 172 - setTimeSinceLastSeen(`${diffMinutes}m ${diffSeconds}s ago`); 173 - } else { 174 - setTimeSinceLastSeen(`${diffSeconds}s ago`); 175 - } 176 - }; 177 - 178 - // Update immediately 179 - updateTimer(); 180 - 181 - // Update every second while offline 182 - const interval = setInterval(updateTimer, 1000); 183 - 184 - return () => clearInterval(interval); 185 - }, [offline, segment?.startTime]); 186 - 187 162 const hover = Gesture.Hover().onChange((_) => runOnJS(onPlayerHover)()); 188 163 189 164 return ( 190 165 <GestureDetector gesture={hover}> 191 - <> 192 - <View 193 - style={[layout.position.absolute, h.percent[100], w.percent[100]]} 166 + <View 167 + style={[layout.position.absolute, h.percent[100], w.percent[100]]} 168 + collapsable={false} 169 + > 170 + <MuteOverlay /> 171 + <PlayerUI.AutoplayButton /> 172 + <PlayerUI.ViewerLoadingOverlay /> 173 + <Animated.View 174 + style={[ 175 + layout.position.absolute, 176 + w.percent[100], 177 + { 178 + top: safeAreaInsets.top, 179 + paddingHorizontal: 16, 180 + paddingVertical: 16, 181 + }, 182 + animatedFadeStyle, 183 + ]} 194 184 > 195 - <MuteOverlay /> 196 - <PlayerUI.AutoplayButton /> 197 - <PlayerUI.ViewerLoadingOverlay /> 198 - <Animated.View 185 + <TopControlBar 186 + offline={offline} 187 + isActivelyLive={isActivelyLive} 188 + ingest={ingest} 189 + isChatOpen={isChatOpen || false} 190 + onToggleChat={toggleChat} 191 + embedded={embedded} 192 + /> 193 + </Animated.View> 194 + 195 + {isActivelyLive && isControlsVisible && ( 196 + <View 199 197 style={[ 200 198 layout.position.absolute, 201 - w.percent[100], 202 199 { 203 - paddingHorizontal: 16, 204 - paddingVertical: 16, 200 + transform: [{ translateX: -100 }, { translateY: -25 }], 205 201 }, 206 - animatedFadeStyle, 207 202 ]} 208 203 > 209 - <TopControlBar 210 - offline={offline} 211 - isActivelyLive={isActivelyLive} 212 - ingest={ingest} 213 - isChatOpen={isChatOpen || false} 214 - onToggleChat={toggleChat} 215 - embedded={embedded} 216 - /> 217 - </Animated.View> 218 - 219 - {isActivelyLive && isControlsVisible && ( 220 - <View 204 + <Animated.View 221 205 style={[ 222 - layout.position.absolute, 223 206 { 224 - transform: [{ translateX: -100 }, { translateY: -25 }], 207 + padding: 12, 208 + backgroundColor: "rgba(0, 0, 0, 0.5)", 225 209 }, 210 + r[3], 211 + animatedFadeStyle, 226 212 ]} 227 213 > 228 - <Animated.View 229 - style={[ 230 - { 231 - padding: 12, 232 - backgroundColor: "rgba(0, 0, 0, 0.5)", 233 - }, 234 - r[3], 235 - animatedFadeStyle, 236 - ]} 237 - > 238 - <PlayerUI.MetricsPanel showMetrics={isActivelyLive} /> 239 - </Animated.View> 240 - </View> 241 - )} 214 + <PlayerUI.MetricsPanel showMetrics={isActivelyLive} /> 215 + </Animated.View> 216 + </View> 217 + )} 242 218 243 - <Animated.View 244 - style={[ 245 - layout.position.absolute, 246 - position.bottom[0], 247 - w.percent[100], 248 - { 249 - backgroundColor: "rgba(0, 0, 0, 0.6)", 250 - paddingHorizontal: 16, 251 - paddingVertical: 2, 252 - paddingBottom: 2, 253 - }, 254 - animatedFadeStyle, 255 - ]} 256 - > 257 - <BottomControlBar 258 - ingest={ingest} 259 - pipSupported={pipSupported} 260 - pipActive={pipActive} 261 - onHandlePip={handlePip} 262 - dropdownPortalContainer={dropdownPortalContainer} 263 - showChat={isChatOpen || false} 264 - setShowChat={setIsChatOpen || (() => {})} 265 - /> 266 - </Animated.View> 219 + <Animated.View 220 + style={[ 221 + layout.position.absolute, 222 + position.bottom[0], 223 + w.percent[100], 224 + { 225 + backgroundColor: "rgba(0, 0, 0, 0.6)", 226 + paddingHorizontal: 16, 227 + paddingVertical: 2, 228 + paddingBottom: 2, 229 + }, 230 + animatedFadeStyle, 231 + ]} 232 + > 233 + <BottomControlBar 234 + ingest={ingest} 235 + pipSupported={pipSupported} 236 + pipActive={pipActive} 237 + onHandlePip={handlePip} 238 + dropdownPortalContainer={dropdownPortalContainer} 239 + showChat={isChatOpen || false} 240 + setShowChat={setIsChatOpen || undefined} 241 + /> 242 + </Animated.View> 267 243 268 - {isSelfAndNotLive && ( 269 - <PlayerUI.InputPanel 270 - title={title} 271 - setTitle={setTitle} 272 - ingestStarting={ingestStarting} 273 - toggleGoLive={toggleGoLive} 274 - /> 275 - )} 244 + {isSelfAndNotLive && ( 245 + <PlayerUI.InputPanel 246 + title={title} 247 + setTitle={setTitle} 248 + ingestStarting={ingestStarting} 249 + toggleGoLive={toggleGoLive} 250 + /> 251 + )} 276 252 277 - <PlayerUI.CountdownOverlay 278 - visible={showCountdown} 279 - width={width} 280 - height={height} 281 - onDone={() => { 282 - setShowCountdown(false); 283 - }} 284 - /> 253 + <PlayerUI.CountdownOverlay 254 + visible={showCountdown} 255 + width={width} 256 + height={height} 257 + onDone={() => { 258 + setShowCountdown(false); 259 + }} 260 + /> 285 261 286 - <Toast 287 - open={recordSubmitted} 288 - onOpenChange={setRecordSubmitted} 289 - title="You're live!" 290 - description="We're notifying your followers that you just went live." 291 - duration={5} 292 - /> 293 - </View> 262 + <Toast 263 + open={recordSubmitted} 264 + onOpenChange={setRecordSubmitted} 265 + title="You're live!" 266 + description="We're notifying your followers that you just went live." 267 + duration={5} 268 + /> 294 269 {showMetrics && ( 295 270 <View 296 271 style={[ ··· 310 285 <PlayerUI.MetricsPanel showMetrics={showMetrics} /> 311 286 </View> 312 287 )} 313 - </> 288 + </View> 314 289 </GestureDetector> 315 290 ); 316 291 }
+2 -2
js/app/components/mobile/desktop-ui/bottom-controls.tsx
··· 30 30 onHandlePip: () => void; 31 31 dropdownPortalContainer?: any; 32 32 showChat: boolean; 33 - setShowChat: (show: boolean) => void; 33 + setShowChat?: (show: boolean) => void; 34 34 } 35 35 36 36 export function BottomControlBar({ ··· 103 103 /> 104 104 )} 105 105 {/* if not web, then add the collapse chat controls here */} 106 - {Platform.OS !== "web" && ( 106 + {Platform.OS !== "web" && setShowChat && ( 107 107 <Button 108 108 variant="outline" 109 109 size="sm"
+3 -5
js/app/components/mobile/desktop-ui/live-bubble.tsx
··· 29 29 ]} 30 30 > 31 31 <Code 32 + size="xs" 32 33 style={[ 33 34 text.white, 34 35 { 35 - fontSize: 12, 36 - lineHeight: 8, 37 36 fontWeight: "600", 38 - letterSpacing: 1.5, 37 + letterSpacing: 2, 39 38 }, 40 39 ]} 41 40 > ··· 59 58 > 60 59 <View style={[h[2], w[2], bg.white, { borderRadius: 999 }]} /> 61 60 <Code 61 + size="xs" 62 62 style={[ 63 63 text.white, 64 64 { 65 - fontSize: 12, 66 - lineHeight: 8, 67 65 fontWeight: "600", 68 66 letterSpacing: 2, 69 67 },
+2 -2
js/app/components/mobile/desktop-ui/top-controls.tsx
··· 62 62 ]} 63 63 > 64 64 <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[3]]}> 65 - {Platform.OS !== "web" && ( 65 + {Platform.OS !== "web" && !embedded && ( 66 66 <Pressable 67 67 onPress={() => { 68 68 navigation.canGoBack() ··· 97 97 ]} 98 98 /> 99 99 100 - <View style={[layout.flex.column, gap.all[2]]}> 100 + <View style={[layout.flex.column, gap.all[1]]}> 101 101 <Text 102 102 style={[text.white, { fontSize: 16, fontWeight: "600" }]} 103 103 >
+102 -12
js/app/components/mobile/player.tsx
··· 9 9 PlayerUI, 10 10 RotationProvider, 11 11 Text, 12 + useLivestreamStore, 12 13 usePlayerDimensions, 13 14 usePlayerStore, 15 + useSegment, 14 16 View, 15 17 } from "@streamplace/components"; 16 18 import { gap, h, pt, w } from "@streamplace/components/src/lib/theme/atoms"; ··· 18 20 import { useSidebarControl } from "hooks/useSidebarControl"; 19 21 import { ArrowLeft, ArrowRight } from "lucide-react-native"; 20 22 import { ComponentRef, useEffect, useRef, useState } from "react"; 21 - import { Animated, Platform, ScrollView, StatusBar } from "react-native"; 22 - import { SafeAreaView } from "react-native-safe-area-context"; 23 - import { useStore } from "store"; 23 + import { Platform, ScrollView, StatusBar } from "react-native"; 24 + import Reanimated, { 25 + useAnimatedStyle, 26 + useSharedValue, 27 + withTiming, 28 + } from "react-native-reanimated"; 24 29 import { useUserProfile } from "store/hooks"; 25 30 import { BottomMetadata } from "./bottom-metadata"; 26 31 import { DesktopChatPanel } from "./chat"; ··· 29 34 import { MobileUi } from "./ui"; 30 35 import { useResponsiveLayout } from "./useResponsiveLayout"; 31 36 37 + import { useSafeAreaInsets } from "react-native-safe-area-context"; 38 + import { useStore } from "store"; 39 + import { UserOffline } from "./user-offline"; 40 + 41 + const SEGMENT_TIMEOUT = 500; // half a sec 42 + 32 43 export function Player( 33 44 props: Partial<PlayerProps> & { 34 45 setFullscreen?: (fullscreen: boolean) => void; ··· 38 49 const { shouldShowChatSidePanel, chatPanelWidth } = useResponsiveLayout(); 39 50 const chatVisible = shouldShowChatSidePanel && showChat; 40 51 52 + const websocketConnected = useLivestreamStore((x) => x.websocketConnected); 53 + const hasReceivedSegment = useLivestreamStore((x) => x.hasReceivedSegment); 54 + const [showUnavailable, setShowUnavailable] = useState(false); 55 + const segs = useSegment(); 56 + 57 + // periodically check if segment has become stale 58 + const [now, setNow] = useState(Date.now()); 59 + useEffect(() => { 60 + const interval = setInterval(() => { 61 + setNow(Date.now()); 62 + }, 15000); // check every 15 seconds 63 + return () => clearInterval(interval); 64 + }, []); 65 + 66 + useEffect(() => { 67 + if (!websocketConnected) { 68 + setShowUnavailable(false); 69 + return; 70 + } 71 + 72 + const then = new Date(segs?.startTime || 0).getTime(); 73 + const segmentIsStale = segs?.startTime ? then < now - 300_000 : true; 74 + 75 + if (!segmentIsStale) { 76 + setShowUnavailable(false); 77 + return; 78 + } 79 + 80 + const timer = setTimeout(() => { 81 + setShowUnavailable(true); 82 + }, SEGMENT_TIMEOUT); 83 + return () => clearTimeout(timer); 84 + }, [websocketConnected, hasReceivedSegment, segs, now]); 85 + 41 86 const [isStreamingElsewhere, setIsStreamingElsewhere] = useState< 42 87 boolean | null 43 88 >(null); ··· 137 182 {...props} 138 183 showChat={showChat} 139 184 setShowChat={setShowChat} 185 + showUnavailable={showUnavailable} 140 186 /> 141 187 {shouldShowChatSidePanel ? ( 142 188 <DesktopChatPanel ··· 144 190 chatPanelWidth={chatPanelWidth} 145 191 /> 146 192 ) : ( 147 - <MobileUi /> 193 + !showUnavailable && <MobileUi /> 148 194 )} 149 195 </View> 150 196 </PlayerProvider> ··· 157 203 props: Partial<PlayerProps> & { 158 204 showChat: boolean; 159 205 setShowChat: (show: boolean) => void; 206 + showUnavailable: boolean; 160 207 }, 161 208 ) { 162 209 let sb = useSidebarControl(); ··· 174 221 showChatSidePanelOnLandscape: props.showChat, 175 222 }); 176 223 224 + const safeAreaInsets = useSafeAreaInsets(); 177 225 const setSidebarHidden = useStore((state) => state.setSidebarHidden); 178 226 const setSidebarUnhidden = useStore((state) => state.setSidebarUnhidden); 179 227 228 + // auto-collapse chat once when going offline 229 + const hasCollapsedChat = useRef(false); 230 + useEffect(() => { 231 + if ( 232 + props.showUnavailable && 233 + shouldShowChatSidePanel && 234 + !hasCollapsedChat.current 235 + ) { 236 + props.setShowChat(false); 237 + hasCollapsedChat.current = true; 238 + } 239 + if (!props.showUnavailable) { 240 + hasCollapsedChat.current = false; 241 + } 242 + }, [props.showUnavailable, shouldShowChatSidePanel]); 243 + 244 + // animated height for offline state 245 + const heightMultiplier = useSharedValue(1); 246 + 247 + useEffect(() => { 248 + if (props.showUnavailable) { 249 + heightMultiplier.value = withTiming(0.65, { duration: 500 }); 250 + } else { 251 + heightMultiplier.value = withTiming(1, { duration: 500 }); 252 + } 253 + }, [props.showUnavailable]); 254 + 180 255 // content info 181 256 const { width, height } = usePlayerDimensions(); 182 257 ··· 213 288 const showFullDesktopMode = aspectRatio > 1 && screenWidth > 1200; 214 289 const isLandscape = aspectRatio > 1; 215 290 291 + const isPlayerRatioGreater = aspectRatio >= 16 / 9; 292 + 293 + // animated style for offline height transition 294 + const animatedHeightStyle = useAnimatedStyle(() => { 295 + return { 296 + height: showFullDesktopMode 297 + ? calculatedHeight * heightMultiplier.value 298 + : undefined, 299 + }; 300 + }); 301 + 216 302 return ( 217 303 <ScrollView 218 304 style={{ ··· 234 320 bounces={false} 235 321 showsVerticalScrollIndicator={false} 236 322 > 237 - <Animated.View 323 + <Reanimated.View 238 324 style={[ 239 325 showFullDesktopMode 240 326 ? { 241 327 width: calculatedWidth, 242 - height: calculatedHeight, 243 328 } 244 329 : { 245 330 flex: 1, 246 331 maxHeight: "auto", 247 332 }, 248 333 { 249 - // paddingTop: 250 - // isPlayerRatioGreater && !isLandscape ? safeAreaInsets.top : 0, 334 + paddingTop: 335 + isPlayerRatioGreater && !isLandscape && !props.showUnavailable 336 + ? safeAreaInsets.top 337 + : 0, 251 338 }, 339 + animatedHeightStyle, 252 340 ]} 253 341 > 254 - <SafeAreaView edges={["left", "top"]} style={{ flex: 1 }}> 342 + {props.showUnavailable ? ( 343 + <UserOffline /> 344 + ) : ( 255 345 <PlayerInnerInner {...props}> 256 346 {showFullDesktopMode || fullscreen ? ( 257 347 <DesktopUi dropdownPortalContainer={dropdownPortalRef.current} /> ··· 264 354 ) 265 355 )} 266 356 <PlayerUI.ViewerLoadingOverlay /> 267 - <OfflineCounter isMobile={true} /> 357 + {!props.showUnavailable && <OfflineCounter isMobile={true} />} 268 358 <View 269 359 ref={dropdownPortalRef} 270 360 style={{ ··· 277 367 }} 278 368 /> 279 369 </PlayerInnerInner> 280 - </SafeAreaView> 281 - </Animated.View> 370 + )} 371 + </Reanimated.View> 282 372 {showFullDesktopMode && ( 283 373 <BottomMetadata 284 374 setShowChat={props.setShowChat}
+4
js/app/components/mobile/ui.tsx
··· 50 50 export function MobileUi({ 51 51 setShowChat, 52 52 showChat, 53 + hideMobileChat, 54 + embed = false, 53 55 }: { 54 56 setShowChat?: (show: boolean) => void; 55 57 showChat?: boolean; 58 + hideMobileChat?: boolean; 59 + embed?: boolean; 56 60 }) { 57 61 const { theme } = useTheme(); 58 62 const navigation = useNavigation();
+464
js/app/components/mobile/user-offline.tsx
··· 1 + import { useNavigation } from "@react-navigation/native"; 2 + import { 3 + LivestreamProvider, 4 + Player, 5 + PlayerProvider, 6 + Text, 7 + Trans, 8 + useAvatars, 9 + useLivestreamStore, 10 + useTranslation, 11 + View, 12 + zero, 13 + } from "@streamplace/components"; 14 + import { overflow } from "@streamplace/components/src/lib/theme/atoms"; 15 + import { ChevronLeft } from "lucide-react-native"; 16 + import { memo, useEffect, useMemo, useState } from "react"; 17 + import { Image, Platform, Pressable, useWindowDimensions } from "react-native"; 18 + import { useStore } from "../../store"; 19 + import FollowButton from "../follow-button"; 20 + import { DesktopUi } from "./desktop-ui"; 21 + 22 + const { bg, borders, flex, gap, h, layout, mt, position, px, py, r, text, w } = 23 + zero; 24 + 25 + interface SourceType { 26 + did: string; 27 + source: string; 28 + } 29 + 30 + export const UserOffline = memo(() => { 31 + console.log("rendering offline"); 32 + const { t } = useTranslation("common"); 33 + const navigation = useNavigation(); 34 + const profile = useLivestreamStore((x) => x.profile); 35 + const { width, height } = useWindowDimensions(); 36 + 37 + const { isSmallScreen, isLandscape, useCompactLayout } = useMemo(() => { 38 + const isSmall = width < 1250; 39 + const isLand = width > height; 40 + return { 41 + isSmallScreen: isSmall, 42 + isLandscape: isLand, 43 + useCompactLayout: isSmall && !isLand, 44 + }; 45 + }, [width, height]); 46 + 47 + const [recommendedSource, setRecommendedSource] = useState<SourceType | null>( 48 + null, 49 + ); 50 + const [isLoadingRecommendation, setIsLoadingRecommendation] = useState(false); 51 + const getRecommendations = useStore((state) => state.getRecommendations); 52 + 53 + const pfp = useAvatars(profile ? [profile?.did] : []); 54 + 55 + // use the detailed profile from useAvatars 56 + const detailedProfile = profile ? pfp[profile?.did] : null; 57 + 58 + useEffect(() => { 59 + if (!profile?.did) return; 60 + 61 + let mounted = true; 62 + 63 + const fetchRecommendation = async () => { 64 + setIsLoadingRecommendation(true); 65 + try { 66 + console.log("fetching recommendations for", profile.did); 67 + const result = await getRecommendations(profile.did); 68 + if (!mounted) return; 69 + if (result.recommendations && result.recommendations.length > 0) { 70 + // Get the first livestream recommendation 71 + const firstLivestream = result.recommendations.find( 72 + (rec) => 73 + rec.$type === 74 + "place.stream.live.getRecommendations#livestreamRecommendation", 75 + ); 76 + if (firstLivestream?.did) { 77 + setRecommendedSource({ 78 + did: firstLivestream.did, 79 + source: firstLivestream.source || "default", 80 + }); 81 + } 82 + } 83 + } catch (err) { 84 + console.error("failed to get recommendations", err); 85 + } finally { 86 + if (mounted) setIsLoadingRecommendation(false); 87 + } 88 + }; 89 + 90 + fetchRecommendation(); 91 + return () => { 92 + mounted = false; 93 + }; 94 + }, [profile?.did, getRecommendations]); 95 + 96 + if (!profile) { 97 + return ( 98 + <View style={[flex.values[1], bg.gray[900], layout.flex.center]}> 99 + <Text size="2xl" color="muted"> 100 + {t("user-offline")} 101 + </Text> 102 + </View> 103 + ); 104 + } 105 + 106 + if (!isLoadingRecommendation && !recommendedSource) { 107 + return ( 108 + <View 109 + style={[ 110 + flex.values[1], 111 + useCompactLayout ? layout.flex.alignCenter : layout.flex.center, 112 + useCompactLayout ? mt[12] : mt[4], 113 + ]} 114 + > 115 + {/* Back Button and Profile */} 116 + {Platform.OS !== "web" && ( 117 + <View 118 + style={[ 119 + { 120 + padding: 3, 121 + paddingRight: 8, 122 + backgroundColor: "rgba(90,90,90, 0.25)", 123 + borderRadius: 12, 124 + alignSelf: "flex-start", 125 + zIndex: 100, 126 + }, 127 + r.lg, 128 + layout.position.absolute, 129 + position.left[4], 130 + useCompactLayout ? position.top[4] : position.top[0], 131 + ]} 132 + > 133 + <View style={[layout.flex.row, layout.flex.center, gap.all[2]]}> 134 + <Pressable 135 + onPress={() => { 136 + if (navigation.canGoBack()) { 137 + navigation.goBack(); 138 + } else { 139 + navigation.reset({ 140 + index: 0, 141 + routes: [ 142 + { name: "Home", params: { screen: "StreamList" } }, 143 + ], 144 + }); 145 + } 146 + }} 147 + > 148 + <ChevronLeft color="white" /> 149 + </Pressable> 150 + <Image 151 + source={ 152 + profile?.did 153 + ? { uri: detailedProfile?.avatar } 154 + : require("assets/images/goose.png") 155 + } 156 + style={[ 157 + { 158 + width: 36, 159 + height: 36, 160 + backgroundColor: "green", 161 + }, 162 + { borderRadius: 999 }, 163 + borders.width.thin, 164 + borders.color.gray[700], 165 + ]} 166 + /> 167 + <Text>{profile?.handle}</Text> 168 + </View> 169 + </View> 170 + )} 171 + {/* Banner Background */} 172 + {detailedProfile?.banner && ( 173 + <Image 174 + blurRadius={10} 175 + source={{ uri: detailedProfile.banner }} 176 + style={[ 177 + { 178 + position: "absolute", 179 + top: -50, 180 + left: 0, 181 + right: 0, 182 + bottom: 0, 183 + width: "100%", 184 + height: "110%", 185 + opacity: 0.15, 186 + }, 187 + ]} 188 + /> 189 + )} 190 + 191 + <View 192 + style={[ 193 + useCompactLayout ? mt[20] : layout.flex.row, 194 + gap.all[isLandscape && isSmallScreen ? 3 : 6], 195 + layout.flex.center, 196 + px[4], 197 + ]} 198 + > 199 + <View 200 + style={[ 201 + isLandscape && isSmallScreen ? { width: 280 } : w.percent[100], 202 + useCompactLayout ? h.auto : h.percent[100], 203 + useCompactLayout 204 + ? { maxWidth: "100%" } 205 + : isLandscape && isSmallScreen 206 + ? { maxWidth: 300 } 207 + : { maxWidth: 450 }, 208 + isLandscape && isSmallScreen ? px[4] : px[8], 209 + isLandscape && isSmallScreen ? py[3] : py[6], 210 + bg.neutral[900], 211 + r.lg, 212 + borders.color.neutral[800], 213 + borders.width.thin, 214 + gap.row[isLandscape && isSmallScreen ? 1 : 2], 215 + layout.flex.justify.center, 216 + ]} 217 + > 218 + <Text size={isLandscape && isSmallScreen ? "base" : "xl"}> 219 + <Trans 220 + i18nKey="user-offline-no-recommendations" 221 + ns="common" 222 + values={{ handle: profile.handle }} 223 + components={{ 224 + 1: ( 225 + <Text 226 + size={isLandscape && isSmallScreen ? "base" : "xl"} 227 + style={[text.gray[400]]} 228 + /> 229 + ), 230 + br: <Text />, 231 + }} 232 + /> 233 + </Text> 234 + </View> 235 + </View> 236 + </View> 237 + ); 238 + } 239 + 240 + return ( 241 + <View 242 + style={[ 243 + flex.values[1], 244 + useCompactLayout ? layout.flex.alignCenter : layout.flex.center, 245 + useCompactLayout ? mt[12] : mt[4], 246 + ]} 247 + > 248 + {/* Back Button and Profile */} 249 + {Platform.OS !== "web" && ( 250 + <View 251 + style={[ 252 + { 253 + padding: 3, 254 + paddingRight: 8, 255 + backgroundColor: "rgba(90,90,90, 0.25)", 256 + borderRadius: 12, 257 + alignSelf: "flex-start", 258 + zIndex: 100, 259 + }, 260 + r.lg, 261 + layout.position.absolute, 262 + position.left[4], 263 + useCompactLayout ? position.top[4] : position.top[0], 264 + ]} 265 + > 266 + <View style={[layout.flex.row, layout.flex.center, gap.all[2]]}> 267 + <Pressable 268 + onPress={() => { 269 + if (navigation.canGoBack()) { 270 + navigation.goBack(); 271 + } else { 272 + navigation.reset({ 273 + index: 0, 274 + routes: [ 275 + { name: "Home", params: { screen: "StreamList" } }, 276 + ], 277 + }); 278 + } 279 + }} 280 + > 281 + <ChevronLeft color="white" /> 282 + </Pressable> 283 + <Image 284 + source={ 285 + profile?.did 286 + ? { uri: detailedProfile?.avatar } 287 + : require("assets/images/goose.png") 288 + } 289 + style={[ 290 + { 291 + width: 36, 292 + height: 36, 293 + backgroundColor: "green", 294 + }, 295 + { borderRadius: 999 }, 296 + borders.width.thin, 297 + borders.color.gray[700], 298 + ]} 299 + /> 300 + <Text>{profile?.handle}</Text> 301 + </View> 302 + </View> 303 + )} 304 + {/* Banner Background */} 305 + {detailedProfile?.banner && ( 306 + <Image 307 + blurRadius={10} 308 + source={{ uri: detailedProfile.banner }} 309 + style={[ 310 + { 311 + position: "absolute", 312 + top: -50, 313 + left: 0, 314 + right: 0, 315 + bottom: 0, 316 + width: "100%", 317 + height: "110%", 318 + opacity: 0.15, 319 + }, 320 + ]} 321 + /> 322 + )} 323 + 324 + {recommendedSource && ( 325 + <LivestreamProvider src={recommendedSource.did} ignoreOuterContext> 326 + <View 327 + style={[ 328 + useCompactLayout ? mt[20] : layout.flex.row, 329 + gap.all[isLandscape && isSmallScreen ? 3 : 6], 330 + layout.flex.center, 331 + px[4], 332 + ]} 333 + > 334 + <View 335 + style={[ 336 + isLandscape && isSmallScreen ? { width: 280 } : w.percent[100], 337 + useCompactLayout ? h.auto : h.percent[100], 338 + useCompactLayout 339 + ? { maxWidth: "100%" } 340 + : isLandscape && isSmallScreen 341 + ? { maxWidth: 280 } 342 + : { maxWidth: 400 }, 343 + isLandscape && isSmallScreen ? px[4] : px[8], 344 + isLandscape && isSmallScreen ? py[3] : py[6], 345 + bg.neutral[900], 346 + r.lg, 347 + borders.color.neutral[800], 348 + borders.width.thin, 349 + gap.row[isLandscape && isSmallScreen ? 1 : 2], 350 + layout.flex.justify.center, 351 + ]} 352 + > 353 + <Text size={isLandscape && isSmallScreen ? "base" : "xl"}> 354 + <Trans 355 + i18nKey="user-offline-message" 356 + ns="common" 357 + values={{ 358 + handle: profile.handle, 359 + source: recommendedSource.source, 360 + }} 361 + components={{ 362 + 1: ( 363 + <Text 364 + size={isLandscape && isSmallScreen ? "base" : "xl"} 365 + style={[text.gray[400]]} 366 + /> 367 + ), 368 + }} 369 + /> 370 + </Text> 371 + <View style={[gap.all[1]]}> 372 + {isLoadingRecommendation ? ( 373 + <Text style={[text.gray[300]]}>{t("loading")}</Text> 374 + ) : ( 375 + <RecommendedSourceInfo /> 376 + )} 377 + </View> 378 + </View> 379 + <View 380 + style={[ 381 + useCompactLayout 382 + ? [w.percent[100], { maxWidth: "100%", aspectRatio: 16 / 9 }] 383 + : [ 384 + flex.values[1], 385 + { 386 + aspectRatio: 16 / 9, 387 + ...(!(isLandscape && isSmallScreen) && { 388 + maxWidth: 650, 389 + minWidth: 650, 390 + }), 391 + }, 392 + ], 393 + overflow.hidden, 394 + r.lg, 395 + overflow.hidden, 396 + borders.color.neutral[800], 397 + borders.width.thin, 398 + bg.black, 399 + gap.row[2], 400 + ]} 401 + > 402 + {!isLoadingRecommendation && ( 403 + <PlayerProvider> 404 + <Player src={recommendedSource.did} embedded={true}> 405 + <DesktopUi setIsChatOpen={undefined} /> 406 + </Player> 407 + </PlayerProvider> 408 + )} 409 + </View> 410 + </View> 411 + </LivestreamProvider> 412 + )} 413 + </View> 414 + ); 415 + }); 416 + 417 + const RecommendedSourceInfo = memo(() => { 418 + const { t } = useTranslation("common"); 419 + const profile = useLivestreamStore((x) => x.profile); 420 + const viewers = useLivestreamStore((x) => x.viewers); 421 + const lsInfo = useLivestreamStore((x) => x.livestream); 422 + const currentUserDID = useStore((state) => state.oauthSession?.did); 423 + 424 + const pfp = useAvatars(profile?.did ? [profile.did] : []); 425 + const detailedProfile = profile?.did ? pfp[profile.did] : null; 426 + 427 + return ( 428 + <View 429 + style={[ 430 + layout.flex.column, 431 + layout.flex.justifyCenter, 432 + gap.all[4], 433 + w.percent[100], 434 + ]} 435 + > 436 + <View style={[layout.flex.column, gap.all[4]]}> 437 + <View style={[layout.flex.row, gap.all[4], layout.flex.alignCenter]}> 438 + <Image 439 + source={{ uri: detailedProfile?.avatar || profile?.avatar }} 440 + style={[ 441 + { width: 48, height: 48, borderRadius: 999 }, 442 + borders.width.thin, 443 + borders.color.gray[700], 444 + ]} 445 + /> 446 + <View style={[flex.values[1]]}> 447 + <Text weight="bold"> 448 + @{detailedProfile?.handle || profile?.handle} 449 + </Text> 450 + <Text style={[text.gray[300]]} size="base"> 451 + {t("viewer-count", { count: viewers || 0 })} 452 + </Text> 453 + </View> 454 + </View> 455 + </View> 456 + {profile?.did && ( 457 + <FollowButton 458 + streamerDID={profile.did} 459 + currentUserDID={currentUserDID} 460 + /> 461 + )} 462 + </View> 463 + ); 464 + });
+696
js/app/components/settings/recommendations-manager.tsx
··· 1 + import { 2 + Button, 3 + Input, 4 + MenuContainer, 5 + MenuGroup, 6 + MenuInfo, 7 + MenuItem, 8 + MenuSeparator, 9 + ResponsiveDialog, 10 + Text, 11 + useToast, 12 + zero, 13 + } from "@streamplace/components"; 14 + import { usePDSAgent } from "@streamplace/components/src/streamplace-store/xrpc"; 15 + import Loading from "components/loading/loading"; 16 + import { 17 + Check, 18 + GripVertical, 19 + Pencil, 20 + Plus, 21 + RefreshCw, 22 + Search, 23 + X, 24 + } from "lucide-react-native"; 25 + import { useCallback, useEffect, useState } from "react"; 26 + import { useTranslation } from "react-i18next"; 27 + import { Pressable, ScrollView, View } from "react-native"; 28 + import Sortable from "react-native-sortables"; 29 + import { SettingsRowItem } from "./components/settings-navigation-item"; 30 + 31 + const { text, mt, mb, px, py, w, layout, gap, r, p } = zero; 32 + 33 + interface ActorSearchResult { 34 + did: string; 35 + handle: string; 36 + } 37 + 38 + export default function RecommendationsManager() { 39 + const agent = usePDSAgent(); 40 + const { theme } = zero.useTheme(); 41 + const [streamers, setStreamers] = useState<string[]>([]); 42 + const [loading, setLoading] = useState(true); 43 + const [saving, setSaving] = useState(false); 44 + const [deleteDialog, setDeleteDialog] = useState<{ 45 + isVisible: boolean; 46 + index: number | null; 47 + }>({ isVisible: false, index: null }); 48 + const [errors, setErrors] = useState<Record<number, string>>({}); 49 + const [editingIndex, setEditingIndex] = useState<number | null>(null); 50 + const [editValue, setEditValue] = useState(""); 51 + 52 + const a = useToast(); 53 + 54 + const [activeDrag, setActiveDrag] = useState(""); 55 + 56 + // Search state 57 + const [searchQuery, setSearchQuery] = useState(""); 58 + const [searchResults, setSearchResults] = useState<ActorSearchResult[]>([]); 59 + const [searching, setSearching] = useState(false); 60 + const [searchDebounceTimeout, setSearchDebounceTimeout] = 61 + useState<NodeJS.Timeout | null>(null); 62 + 63 + const { t } = useTranslation("settings"); 64 + 65 + const loadRecommendations = async () => { 66 + if (!agent) return; 67 + 68 + try { 69 + setLoading(true); 70 + const userDID = agent.did; 71 + if (!userDID) { 72 + setStreamers([]); 73 + return; 74 + } 75 + 76 + // Get the record directly from the PDS for editing 77 + const response = await agent.com.atproto.repo.getRecord({ 78 + repo: userDID, 79 + collection: "place.stream.live.recommendations", 80 + rkey: "self", 81 + }); 82 + 83 + // todo: type this right 84 + let record = response.data.value as any; 85 + 86 + if (!response.success) { 87 + // Create a new empty record if not found 88 + const res = await agent.com.atproto.repo.createRecord({ 89 + repo: userDID, 90 + collection: "place.stream.live.recommendations", 91 + record: { 92 + streamers: [], 93 + createdAt: new Date().toISOString(), 94 + }, 95 + }); 96 + if (!res.success) { 97 + throw new Error("Failed to create recommendations record"); 98 + } 99 + record = res.data; 100 + } 101 + setStreamers(record.streamers || []); 102 + } catch (error: any) { 103 + console.error("Failed to load recommendations:", error); 104 + if (error.status !== 404) { 105 + a.show("Error", "Failed to load recommendations. Please try again."); 106 + } 107 + setStreamers([]); 108 + } finally { 109 + setLoading(false); 110 + } 111 + }; 112 + 113 + const saveRecommendations = async (newStreamers: string[]) => { 114 + if (!agent || saving) return; 115 + 116 + try { 117 + if (!agent.did) { 118 + throw new Error("User DID not found"); 119 + } 120 + setSaving(true); 121 + 122 + // Use putRecord to create or update the record 123 + await agent.com.atproto.repo.putRecord({ 124 + repo: agent.did, 125 + collection: "place.stream.live.recommendations", 126 + rkey: "self", 127 + record: { 128 + streamers: newStreamers, 129 + createdAt: new Date().toISOString(), 130 + }, 131 + }); 132 + 133 + setStreamers(newStreamers); 134 + } catch (error: any) { 135 + console.error("Failed to save recommendations:", error); 136 + a.show( 137 + "Error", 138 + error.message || "Failed to save recommendations. Please try again.", 139 + ); 140 + // Reload to get back to consistent state 141 + await loadRecommendations(); 142 + } finally { 143 + setSaving(false); 144 + } 145 + }; 146 + 147 + const searchActors = useCallback( 148 + async (query: string) => { 149 + if (!agent || !query.trim()) { 150 + setSearchResults([]); 151 + return; 152 + } 153 + 154 + try { 155 + setSearching(true); 156 + const response = await agent.place.stream.live.searchActorsTypeahead({ 157 + q: query, 158 + limit: 10, 159 + }); 160 + 161 + setSearchResults( 162 + response.data.actors.map((actor: any) => ({ 163 + did: actor.did, 164 + handle: actor.handle, 165 + })), 166 + ); 167 + } catch (error: any) { 168 + console.error("Failed to search actors:", error); 169 + setSearchResults([]); 170 + } finally { 171 + setSearching(false); 172 + } 173 + }, 174 + [agent], 175 + ); 176 + 177 + const handleSearchChange = (query: string) => { 178 + setSearchQuery(query); 179 + 180 + // Clear previous timeout 181 + if (searchDebounceTimeout) { 182 + clearTimeout(searchDebounceTimeout); 183 + } 184 + 185 + // Set new timeout for debounced search 186 + if (query.trim()) { 187 + const timeout = setTimeout(() => { 188 + searchActors(query); 189 + }, 300); 190 + setSearchDebounceTimeout(timeout); 191 + } else { 192 + setSearchResults([]); 193 + } 194 + }; 195 + 196 + const handleSelectActor = async (actor: ActorSearchResult) => { 197 + if (streamers.length >= 8) { 198 + a.show("Maximum Reached", "You can only add up to 8 recommendations."); 199 + return; 200 + } 201 + 202 + if (streamers.includes(actor.did)) { 203 + a.show( 204 + "Already Added", 205 + "This streamer is already in your recommendations.", 206 + ); 207 + return; 208 + } 209 + 210 + const newStreamers = [...streamers, actor.did]; 211 + await saveRecommendations(newStreamers); 212 + 213 + // Clear search 214 + setSearchQuery(""); 215 + setSearchResults([]); 216 + }; 217 + 218 + const validateDID = (did: string, index: number): boolean => { 219 + const trimmed = did.trim(); 220 + if (!trimmed) { 221 + setErrors((prev) => ({ ...prev, [index]: "DID is required" })); 222 + return false; 223 + } 224 + if (!trimmed.startsWith("did:")) { 225 + setErrors((prev) => ({ 226 + ...prev, 227 + [index]: "DID must start with 'did:'", 228 + })); 229 + return false; 230 + } 231 + setErrors((prev) => { 232 + const newErrors = { ...prev }; 233 + delete newErrors[index]; 234 + return newErrors; 235 + }); 236 + return true; 237 + }; 238 + 239 + const handleEdit = (index: number) => { 240 + setEditingIndex(index); 241 + setEditValue(streamers[index]); 242 + setErrors({}); 243 + }; 244 + 245 + const handleCancelEdit = () => { 246 + setEditingIndex(null); 247 + setEditValue(""); 248 + setErrors({}); 249 + }; 250 + 251 + const handleSaveEdit = async () => { 252 + if (editingIndex === null) return; 253 + 254 + const trimmed = editValue.trim(); 255 + if (!trimmed) { 256 + setErrors({ [editingIndex]: "DID is required" }); 257 + return; 258 + } 259 + if (!trimmed.startsWith("did:")) { 260 + setErrors({ [editingIndex]: "DID must start with 'did:'" }); 261 + return; 262 + } 263 + 264 + const newStreamers = [...streamers]; 265 + newStreamers[editingIndex] = trimmed; 266 + await saveRecommendations(newStreamers); 267 + setEditingIndex(null); 268 + setEditValue(""); 269 + setErrors({}); 270 + }; 271 + 272 + const handleAddRecommendation = () => { 273 + if (streamers.length >= 8) { 274 + a.show("Maximum Reached", "You can only add up to 8 recommendations."); 275 + return; 276 + } 277 + const newIndex = streamers.length; 278 + setStreamers([...streamers, ""]); 279 + setEditingIndex(newIndex); 280 + setEditValue(""); 281 + }; 282 + 283 + const handleDelete = (index: number) => { 284 + setDeleteDialog({ isVisible: true, index }); 285 + }; 286 + 287 + const confirmDelete = async () => { 288 + if (deleteDialog.index === null) return; 289 + 290 + const newStreamers = streamers.filter((_, i) => i !== deleteDialog.index); 291 + await saveRecommendations(newStreamers); 292 + setDeleteDialog({ isVisible: false, index: null }); 293 + }; 294 + 295 + useEffect(() => { 296 + if (!agent) return; 297 + loadRecommendations(); 298 + }, [agent]); 299 + 300 + // Cleanup timeout on unmount 301 + useEffect(() => { 302 + return () => { 303 + if (searchDebounceTimeout) { 304 + clearTimeout(searchDebounceTimeout); 305 + } 306 + }; 307 + }, [searchDebounceTimeout]); 308 + 309 + if (!agent) { 310 + return <Loading />; 311 + } 312 + 313 + return ( 314 + <> 315 + <ScrollView> 316 + <View style={[zero.layout.flex.align.center, zero.px[2], zero.py[2]]}> 317 + <View style={{ maxWidth: 800, width: "100%" }}> 318 + <MenuContainer> 319 + <View style={[mb[2]]}> 320 + <View 321 + style={[ 322 + layout.flex.row, 323 + layout.flex.justify.between, 324 + layout.flex.alignCenter, 325 + ]} 326 + > 327 + <Text size="xl">{t("recommendations-to-others")}</Text> 328 + <Button 329 + onPress={loadRecommendations} 330 + disabled={loading || saving} 331 + leftIcon={<RefreshCw size={16} color={theme.colors.text} />} 332 + size="pill" 333 + width="min" 334 + variant="secondary" 335 + > 336 + <Text size="sm">{t("refresh")}</Text> 337 + </Button> 338 + </View> 339 + </View> 340 + 341 + <MenuInfo description={t("recommendations-description")} /> 342 + </MenuContainer> 343 + 344 + {/* Search Bar */} 345 + {streamers.length < 8 && ( 346 + <MenuContainer> 347 + <MenuGroup> 348 + <View style={[px[3], py[2]]}> 349 + <View 350 + style={[ 351 + layout.flex.row, 352 + layout.flex.alignCenter, 353 + gap.all[2], 354 + ]} 355 + > 356 + <Search size={20} color={theme.colors.textMuted} /> 357 + <Input 358 + value={searchQuery} 359 + onChangeText={handleSearchChange} 360 + placeholder="Search for streamers..." 361 + /> 362 + </View> 363 + </View> 364 + 365 + {searching && ( 366 + <> 367 + <MenuSeparator /> 368 + <View style={[py[2], layout.flex.center]}> 369 + <Text 370 + size="sm" 371 + style={{ color: theme.colors.textMuted }} 372 + > 373 + Searching... 374 + </Text> 375 + </View> 376 + </> 377 + )} 378 + 379 + {!searching && searchResults.length > 0 && ( 380 + <> 381 + <MenuSeparator /> 382 + {searchResults.map((actor, index) => { 383 + const alreadyAdded = streamers.includes(actor.did); 384 + return ( 385 + <View key={actor.did}> 386 + {index > 0 && <MenuSeparator />} 387 + <Pressable 388 + onPress={() => 389 + !alreadyAdded && handleSelectActor(actor) 390 + } 391 + disabled={alreadyAdded} 392 + > 393 + {({ pressed }) => ( 394 + <View 395 + style={[ 396 + px[3], 397 + py[2], 398 + layout.flex.row, 399 + layout.flex.alignCenter, 400 + gap.all[2], 401 + r.md, 402 + { 403 + backgroundColor: 404 + pressed && !alreadyAdded 405 + ? "#ffffff08" 406 + : "transparent", 407 + opacity: alreadyAdded ? 0.5 : 1, 408 + }, 409 + ]} 410 + > 411 + <View style={{ flex: 1 }}> 412 + <Text>@{actor.handle}</Text> 413 + </View> 414 + {alreadyAdded && ( 415 + <Text 416 + size="xs" 417 + style={{ color: theme.colors.textMuted }} 418 + > 419 + Added 420 + </Text> 421 + )} 422 + </View> 423 + )} 424 + </Pressable> 425 + </View> 426 + ); 427 + })} 428 + </> 429 + )} 430 + 431 + {!searching && 432 + searchQuery.trim() && 433 + searchResults.length === 0 && ( 434 + <> 435 + <MenuSeparator /> 436 + <View style={[py[2], layout.flex.center]}> 437 + <Text 438 + size="sm" 439 + style={{ color: theme.colors.textMuted }} 440 + > 441 + No results found 442 + </Text> 443 + </View> 444 + </> 445 + )} 446 + </MenuGroup> 447 + 448 + {searchQuery.trim() === "" && ( 449 + <MenuInfo description="Search for streamers by handle or name, or enter DIDs manually below" /> 450 + )} 451 + </MenuContainer> 452 + )} 453 + 454 + {loading ? ( 455 + <Loading /> 456 + ) : ( 457 + <MenuContainer> 458 + <MenuGroup> 459 + {streamers.length === 0 ? ( 460 + <View style={[py[4], layout.flex.center]}> 461 + <Text size="sm" style={{ color: theme.colors.textMuted }}> 462 + {t("no-recommendations-yet")} 463 + </Text> 464 + </View> 465 + ) : ( 466 + <Sortable.Grid 467 + columns={1} 468 + activeItemOpacity={90} 469 + activeItemScale={1} 470 + onActiveItemDropped={() => { 471 + saveRecommendations(streamers); 472 + }} 473 + data={streamers} 474 + keyExtractor={(item: string) => `item-${item}`} 475 + overDrag="vertical" 476 + onDragStart={(e) => { 477 + console.log("dragging", e.key); 478 + setActiveDrag(e.key); 479 + }} 480 + onDragEnd={() => setActiveDrag("")} 481 + renderItem={(params: any) => { 482 + const streamer: string = params.item; 483 + const index: number = params.index ?? 0; 484 + const beforeSeparator = 485 + index > 0 && "item-" + params.item !== activeDrag ? ( 486 + <MenuSeparator key={`sep-${index}`} /> 487 + ) : null; 488 + 489 + return ( 490 + <> 491 + {beforeSeparator} 492 + <MenuItem key={`item-${index}`}> 493 + <GripVertical 494 + color={theme.colors.mutedForeground + "a0"} 495 + size={18} 496 + style={{ 497 + marginLeft: -4, 498 + marginRight: 4, 499 + }} 500 + /> 501 + {editingIndex === index ? ( 502 + <> 503 + <View style={{ flex: 1 }}> 504 + <Input 505 + value={editValue} 506 + onChangeText={setEditValue} 507 + placeholder="did:plc:..." 508 + autoFocus 509 + /> 510 + {errors[index] && ( 511 + <Text 512 + size="xs" 513 + style={{ 514 + color: theme.colors.destructive, 515 + marginTop: 4, 516 + }} 517 + > 518 + {errors[index]} 519 + </Text> 520 + )} 521 + </View> 522 + 523 + <Pressable 524 + onPress={handleSaveEdit} 525 + style={({ pressed }) => [ 526 + { 527 + padding: 8, 528 + borderRadius: 6, 529 + backgroundColor: pressed 530 + ? "#ffffff08" 531 + : "transparent", 532 + }, 533 + ]} 534 + > 535 + <Check 536 + size={18} 537 + color={theme.colors.text} 538 + /> 539 + </Pressable> 540 + 541 + <Pressable 542 + onPress={handleCancelEdit} 543 + style={({ pressed }) => [ 544 + { 545 + padding: 8, 546 + borderRadius: 6, 547 + backgroundColor: pressed 548 + ? "#ffffff08" 549 + : "transparent", 550 + }, 551 + ]} 552 + > 553 + <X 554 + size={18} 555 + color={theme.colors.textMuted} 556 + /> 557 + </Pressable> 558 + </> 559 + ) : ( 560 + <> 561 + <View style={{ flex: 1 }}> 562 + <Text 563 + numberOfLines={1} 564 + ellipsizeMode="middle" 565 + > 566 + {streamer || "(empty)"} 567 + </Text> 568 + </View> 569 + 570 + <Pressable 571 + onPress={() => handleEdit(index)} 572 + style={({ pressed }) => [ 573 + { 574 + padding: 8, 575 + borderRadius: 6, 576 + backgroundColor: pressed 577 + ? "#ffffff08" 578 + : "transparent", 579 + }, 580 + ]} 581 + > 582 + <Pencil 583 + size={18} 584 + color={theme.colors.textMuted} 585 + /> 586 + </Pressable> 587 + 588 + <Pressable 589 + onPress={() => handleDelete(index)} 590 + style={({ pressed }) => [ 591 + { 592 + padding: 8, 593 + borderRadius: 6, 594 + backgroundColor: pressed 595 + ? "#ffffff08" 596 + : "transparent", 597 + }, 598 + ]} 599 + > 600 + <X 601 + size={18} 602 + color={theme.colors.destructive} 603 + /> 604 + </Pressable> 605 + </> 606 + )} 607 + </MenuItem> 608 + </> 609 + ); 610 + }} 611 + onOrderChange={(params) => { 612 + console.log(params); 613 + // calculate new order from params 614 + // duplicate streamers array 615 + const newData = [...streamers]; 616 + const movedItem = newData.splice( 617 + params.fromIndex, 618 + 1, 619 + )[0]; 620 + newData.splice(params.toIndex, 0, movedItem); 621 + setStreamers(newData); 622 + }} 623 + rowGap={0} 624 + columnGap={0} 625 + /> 626 + )} 627 + 628 + {streamers.length > 0 && streamers.length < 8 && ( 629 + <MenuSeparator /> 630 + )} 631 + 632 + {streamers.length < 8 && ( 633 + <SettingsRowItem onPress={handleAddRecommendation}> 634 + <View 635 + style={[ 636 + layout.flex.row, 637 + layout.flex.alignCenter, 638 + gap.all[2], 639 + ]} 640 + > 641 + <Plus color={theme.colors.text} /> 642 + <Text>Add DID manually</Text> 643 + </View> 644 + </SettingsRowItem> 645 + )} 646 + 647 + {saving && ( 648 + <View style={[mt[2], layout.flex.center]}> 649 + <Text size="sm" style={{ color: theme.colors.textMuted }}> 650 + {t("saving")} 651 + </Text> 652 + </View> 653 + )} 654 + </MenuGroup> 655 + </MenuContainer> 656 + )} 657 + </View> 658 + </View> 659 + </ScrollView> 660 + 661 + <ResponsiveDialog 662 + open={deleteDialog.isVisible} 663 + onOpenChange={(open) => 664 + !open && setDeleteDialog({ isVisible: false, index: null }) 665 + } 666 + title={t("delete")} 667 + dismissible={true} 668 + size="sm" 669 + > 670 + <View style={[w.percent[100], gap.all[2], mb[4]]}> 671 + <Text size="2xl">{t("confirm-delete")}</Text> 672 + <Text>{t("action-cannot-be-undone")}</Text> 673 + </View> 674 + 675 + <View style={[layout.flex.row, layout.flex.justify.end, gap.all[3]]}> 676 + <Button 677 + variant="secondary" 678 + width="min" 679 + onPress={() => setDeleteDialog({ isVisible: false, index: null })} 680 + disabled={saving} 681 + > 682 + <Text>{t("cancel")}</Text> 683 + </Button> 684 + <Button 685 + variant="destructive" 686 + width="min" 687 + onPress={confirmDelete} 688 + disabled={saving} 689 + > 690 + <Text>{saving ? t("deleting") : t("delete")}</Text> 691 + </Button> 692 + </View> 693 + </ResponsiveDialog> 694 + </> 695 + ); 696 + }
+7 -1
js/app/components/settings/streaming-category-settings.tsx
··· 5 5 View, 6 6 zero, 7 7 } from "@streamplace/components"; 8 - import { Key, Webhook } from "lucide-react-native"; 8 + import { Heart, Key, Webhook } from "lucide-react-native"; 9 9 import { useTranslation } from "react-i18next"; 10 10 import { ScrollView } from "react-native"; 11 11 import { SettingsNavigationItem } from "./components/settings-navigation-item"; ··· 22 22 title={t("key-management")} 23 23 screen="KeyManagement" 24 24 icon={Key} 25 + /> 26 + <MenuSeparator /> 27 + <SettingsNavigationItem 28 + title={t("recommendations-to-others")} 29 + screen="RecommendationsSettings" 30 + icon={Heart} 25 31 /> 26 32 <MenuSeparator /> 27 33 <SettingsNavigationItem
+2
js/app/package.json
··· 92 92 "react-dom": "19.0.0", 93 93 "react-i18next": "^15.7.3", 94 94 "react-native": "0.79.3", 95 + "react-native-draggable-flatlist": "^4.0.3", 95 96 "react-native-edge-to-edge": "^1.6.2", 96 97 "react-native-gesture-handler": "~2.26.0", 97 98 "react-native-localize": "^3.5.2", ··· 100 101 "react-native-reanimated": "~3.18.0", 101 102 "react-native-safe-area-context": "5.4.1", 102 103 "react-native-screens": "~4.11.1", 104 + "react-native-sortables": "^1.9.4", 103 105 "react-native-svg": "15.12.0", 104 106 "react-native-web": "^0.20.0", 105 107 "react-native-webrtc": "git+https://github.com/streamplace/react-native-webrtc.git#6b8472a771ac47f89217d327058a8a4124a6ae56",
+12 -3
js/app/scripts/generate-build-info.js
··· 3 3 const { execSync } = require("child_process"); 4 4 const fs = require("fs"); 5 5 const path = require("path"); 6 + const pkg = require("../package.json"); 6 7 7 8 function getGitInfo() { 8 9 try { ··· 13 14 const branch = execSync("git rev-parse --abbrev-ref HEAD", { 14 15 encoding: "utf-8", 15 16 }).trim(); 16 - const tag = execSync("git describe --tags --always --dirty", { 17 - encoding: "utf-8", 18 - }).trim(); 17 + 18 + let tag; 19 + try { 20 + tag = execSync("git describe --tags --always --dirty", { 21 + encoding: "utf-8", 22 + }).trim(); 23 + } catch (error) { 24 + // git describe fails in shallow clones, use package.json version + short hash 25 + tag = `v${pkg.version}-${shortHash}`; 26 + } 27 + 19 28 const isDirty = tag.endsWith("-dirty"); 20 29 21 30 return {
+8
js/app/src/router.tsx
··· 73 73 74 74 import { useUrl } from "@streamplace/components"; 75 75 import { LanguagesCategorySettings } from "components/settings/languages-category-settings"; 76 + import RecommendationsManager from "components/settings/recommendations-manager"; 76 77 import Constants from "expo-constants"; 77 78 import { useBlueskyNotifications } from "hooks/useBlueskyNotifications"; 78 79 import { SystemBars } from "react-native-edge-to-edge"; ··· 115 116 AccountCategory: undefined; 116 117 StreamingCategory: undefined; 117 118 WebhooksSettings: undefined; 119 + RecommendationsSettings: undefined; 118 120 PrivacyCategory: undefined; 119 121 DanmuCategory: undefined; 120 122 AdvancedCategory: undefined; ··· 171 173 AccountCategory: "settings/account", 172 174 StreamingCategory: "settings/streaming", 173 175 WebhooksSettings: "settings/streaming/webhooks", 176 + RecommendationsSettings: "settings/streaming/recommendations", 174 177 PrivacyCategory: "settings/privacy", 175 178 DanmuCategory: "settings/danmu", 176 179 AdvancedCategory: "settings/advanced", ··· 765 768 name="WebhooksSettings" 766 769 component={WebhookManager} 767 770 options={{ headerTitle: "Webhooks", title: "Webhooks" }} 771 + /> 772 + <Stack.Screen 773 + name="RecommendationsSettings" 774 + component={RecommendationsManager} 775 + options={{ headerTitle: "Recommendations", title: "Recommendations" }} 768 776 /> 769 777 <Stack.Screen 770 778 name="PrivacyCategory"
+56 -10
js/app/src/screens/mobile-stream.tsx
··· 1 - import { KeepAwake } from "@streamplace/components"; 1 + import { 2 + KeepAwake, 3 + LivestreamProvider, 4 + PlayerProvider, 5 + useLivestreamStore, 6 + } from "@streamplace/components"; 2 7 import { Player } from "components/mobile/player"; 3 8 import { PlayerProps } from "components/player/props"; 4 9 import { FullscreenProvider } from "contexts/FullscreenContext"; 5 10 import useTitle from "hooks/useTitle"; 6 - import { Platform } from "react-native"; 11 + import { Platform, Text, View } from "react-native"; 7 12 import { queryToProps } from "./util"; 8 13 9 14 const isWeb = Platform.OS === "web"; 10 15 16 + function StreamError({ message }: { message: string }) { 17 + return ( 18 + <View 19 + style={{ 20 + flex: 1, 21 + justifyContent: "center", 22 + alignItems: "center", 23 + backgroundColor: "#111", 24 + }} 25 + > 26 + <Text style={{ color: "#fff", fontSize: 18 }}>{message}</Text> 27 + </View> 28 + ); 29 + } 30 + 31 + function MobileStreamInner({ 32 + user, 33 + src, 34 + extraProps, 35 + }: { 36 + user: string; 37 + src: string; 38 + extraProps: Partial<PlayerProps>; 39 + }) { 40 + const problems = useLivestreamStore((x) => x.problems); 41 + 42 + const userNotFoundError = problems.find((p) => p.code === "user_not_found"); 43 + 44 + useTitle(user); 45 + 46 + if (userNotFoundError) { 47 + return <StreamError message={userNotFoundError.message} />; 48 + } 49 + 50 + return ( 51 + <> 52 + <KeepAwake /> 53 + <FullscreenProvider> 54 + <Player src={src} {...extraProps} /> 55 + </FullscreenProvider> 56 + </> 57 + ); 58 + } 59 + 11 60 export default function MobileStream({ route }) { 12 61 const { user, protocol, url } = route.params; 13 62 let extraProps: Partial<PlayerProps> = {}; ··· 19 68 src = url; 20 69 } 21 70 22 - useTitle(user); 23 - 24 71 return ( 25 - <> 26 - <KeepAwake /> 27 - <FullscreenProvider> 28 - <Player src={src} {...extraProps} /> 29 - </FullscreenProvider> 30 - </> 72 + <LivestreamProvider src={src}> 73 + <PlayerProvider> 74 + <MobileStreamInner user={user} src={src} extraProps={extraProps} /> 75 + </PlayerProvider> 76 + </LivestreamProvider> 31 77 ); 32 78 }
+19
js/app/store/slices/streamplaceSlice.ts
··· 38 38 chatWarn: (warned: boolean) => void; 39 39 getIdentity: () => Promise<void>; 40 40 pollMySegments: () => Promise<void>; 41 + getRecommendations: (userDID: string) => Promise<{ 42 + recommendations: Array<{ 43 + $type: string; 44 + did?: string; 45 + source?: string; 46 + uri?: string; 47 + }>; 48 + userDID?: string; 49 + }>; 41 50 } 42 51 43 52 export const createStreamplaceSlice: StateCreator<StreamplaceSlice> = ( ··· 113 122 } catch (err) { 114 123 // silently fail 115 124 } 125 + }, 126 + getRecommendations: async (userDID: string) => { 127 + const state = get() as any; // need to access bluesky slice 128 + if (!state.pdsAgent) { 129 + throw new Error("no pdsAgent"); 130 + } 131 + const result = await state.pdsAgent.place.stream.live.getRecommendations({ 132 + userDID, 133 + }); 134 + return result.data; 116 135 }, 117 136 });
+16
js/components/locales/en-US/common.ftl
··· 44 44 [1] One notification 45 45 *[other] { $count } notifications 46 46 } 47 + 48 + ## Offline User 49 + user-offline = user is offline 50 + user-offline-message = { $source -> 51 + [streamer] Looks like <1>@{ $handle } is offline</1>, but they recommend checking out: 52 + *[default] Looks like <1>@{ $handle } is offline</1>, but we recommend checking out: 53 + } 54 + user-offline-no-recommendations = 55 + Looks like <1>@{ $handle } is offline</1> right now. 56 + Check back later. 57 + streaming-title = streaming { $title } 58 + viewer-count = { $count -> 59 + [0] 0 viewers 60 + [1] 1 viewer 61 + *[other] { $count } viewers 62 + }
+13
js/components/locales/en-US/settings.ftl
··· 80 80 *[other] { $count } keys 81 81 } 82 82 83 + ## Recommendations 84 + recommendations = Recommendations 85 + manage-recommendations = Manage Recommendations 86 + recommendations-to-others = Recommendations to Others 87 + recommendations-description = Share up to 8 streamers you recommend to your viewers 88 + no-recommendations-yet = No recommendations configured yet 89 + add-recommendation = Add Recommendation 90 + streamer-did = Streamer DID 91 + recommendations-count = { $count -> 92 + [one] { $count } recommendation 93 + *[other] { $count } recommendations 94 + } 95 + 83 96 ## Webhook Management 84 97 webhooks = Webhooks 85 98 webhook-integrations = Webhook Integrations
+16
js/components/locales/es-ES/common.ftl
··· 44 44 [1] Una notificación 45 45 *[other] { $count } notificaciones 46 46 } 47 + 48 + ## Offline User 49 + user-offline = usuario desconectado 50 + user-offline-message = { $source -> 51 + [streamer] Parece que <1>@{ $handle } está desconectado</1>, pero ellos recomiendan ver: 52 + *[default] Parece que <1>@{ $handle } está desconectado</1>, pero te recomendamos ver: 53 + } 54 + user-offline-no-recommendations = 55 + Parece que <1>@{ $handle } está desconectado</1> ahora mismo. 56 + Vuelve más tarde. 57 + streaming-title = transmitiendo { $title } 58 + viewer-count = { $count -> 59 + [0] 0 espectadores 60 + [1] 1 espectador 61 + *[other] { $count } espectadores 62 + }
+1 -1
js/components/locales/es-ES/settings.ftl
··· 113 113 manage-keys = Gestionar Claves 114 114 your-stream-pubkeys = Tus Claves Públicas de Transmisión 115 115 no-keys = No hay claves configuradas 116 - pubkey-description = Las claves públicas se emparejan con claves de transmisión (usadas en software de streaming) para firmar y verificar tu transmisión 116 + pubkey-description = Las claves públicas se emparejan con claves de transmisión (usadas en software de transmitiendo) para firmar y verificar tu transmisión 117 117 118 118 keys-count = { $count -> 119 119 [one] { $count } clave
+16
js/components/locales/fr-FR/common.ftl
··· 44 44 [1] Une notification 45 45 *[other] { $count } notifications 46 46 } 47 + 48 + ## Offline User 49 + user-offline = utilisateur hors ligne 50 + user-offline-message = { $source -> 51 + [streamer] On dirait que <1>@{ $handle } est hors ligne</1>, mais ils recommandent de regarder : 52 + *[default] On dirait que <1>@{ $handle } est hors ligne</1>, mais nous recommandons de regarder : 53 + } 54 + user-offline-no-recommendations = 55 + On dirait que <1>@{ $handle } est hors ligne</1> maintenant. 56 + Revenez plus tard. 57 + streaming-title = diffusion de { $title } 58 + viewer-count = { $count -> 59 + [0] 0 spectateurs 60 + [1] 1 spectateur 61 + *[other] { $count } spectateurs 62 + }
+16
js/components/locales/pt-BR/common.ftl
··· 44 44 [1] Uma notificação 45 45 *[other] { $count } notificações 46 46 } 47 + 48 + ## Offline User 49 + user-offline = usuário offline 50 + user-offline-message = { $source -> 51 + [streamer] Parece que <1>@{ $handle } está offline</1>, mas eles recomendam assistir: 52 + *[default] Parece que <1>@{ $handle } está offline</1>, mas recomendamos assistir: 53 + } 54 + user-offline-no-recommendations = 55 + Parece que <1>@{ $handle } está offline</1> agora. 56 + Volte mais tarde. 57 + streaming-title = transmitindo { $title } 58 + viewer-count = { $count -> 59 + [0] 0 espectadores 60 + [1] 1 espectador 61 + *[other] { $count } espectadores 62 + }
+1 -1
js/components/locales/pt-BR/settings.ftl
··· 111 111 manage-keys = Gerenciar Chaves 112 112 your-stream-pubkeys = Suas Chaves Públicas de Transmissão 113 113 no-keys = Nenhuma chave configurada 114 - pubkey-description = Chaves públicas são emparelhadas com chaves de transmissão (usadas em software de streaming) para assinar e verificar sua transmissão 114 + pubkey-description = Chaves públicas são emparelhadas com chaves de transmissão (usadas em software de transmitindo) para assinar e verificar sua transmissão 115 115 116 116 keys-count = { $count -> 117 117 [one] { $count } chave
+16
js/components/locales/zh-Hant/common.ftl
··· 44 44 [1] 一則通知 45 45 *[other] { $count } 則通知 46 46 } 47 + 48 + ## Offline User 49 + user-offline = 使用者離線 50 + user-offline-message = { $source -> 51 + [streamer] 看起來 <1>@{ $handle } 離線</1> 了,但他們推薦觀看: 52 + *[default] 看起來 <1>@{ $handle } 離線</1> 了,但我們推薦觀看: 53 + } 54 + user-offline-no-recommendations = 55 + 看起來 <1>@{ $handle } 離線</1> 了。 56 + 請稍後再來看看。 57 + streaming-title = 正在直播 { $title } 58 + viewer-count = { $count -> 59 + [0] 0 位觀眾 60 + [1] 1 位觀眾 61 + *[other] { $count } 位觀眾 62 + }
+2 -1
js/components/src/components/mobile-player/video-async.native.tsx
··· 414 414 variant="secondary" 415 415 > 416 416 <View style={[layout.flex.row, gap.all[1]]}> 417 - <Text>Open Settings</Text> <ArrowRight color="white" size="18" /> 417 + <Text>Open Settings</Text> 418 + <ArrowRight color="white" size="18" /> 418 419 </View> 419 420 </Button> 420 421 )}
+180 -3
js/components/src/components/ui/menu.tsx
··· 1 - import { forwardRef, ReactNode } from "react"; 2 - import { Platform, View, ViewStyle } from "react-native"; 1 + import { 2 + Children, 3 + cloneElement, 4 + forwardRef, 5 + isValidElement, 6 + ReactNode, 7 + } from "react"; 8 + import { Animated, Platform, View, ViewStyle } from "react-native"; 9 + import { Gesture, GestureDetector } from "react-native-gesture-handler"; 10 + import { 11 + runOnJS, 12 + useAnimatedStyle, 13 + useSharedValue, 14 + withSpring, 15 + } from "react-native-reanimated"; 3 16 import { 4 17 a, 5 18 borderRadius, ··· 64 77 disabled?: boolean; 65 78 style?: ViewStyle; 66 79 onPress?: () => void; 80 + draggable?: boolean; 81 + dragHandle?: ReactNode; 82 + _dragIndex?: number; 83 + _dragTotalItems?: number; 84 + _onDragMove?: (fromIndex: number, toIndex: number) => void; 85 + _onDragEnd?: (fromIndex: number, toIndex: number) => void; 67 86 } 68 87 69 88 export const MenuItem = forwardRef<View, MenuItemProps>( 70 - ({ children, disabled, style }, ref) => { 89 + ( 90 + { 91 + children, 92 + disabled, 93 + style, 94 + draggable, 95 + dragHandle, 96 + _dragIndex, 97 + _dragTotalItems, 98 + _onDragMove, 99 + _onDragEnd, 100 + }, 101 + ref, 102 + ) => { 71 103 const { theme } = useTheme(); 104 + 105 + if ( 106 + draggable && 107 + _dragIndex !== undefined && 108 + _dragTotalItems !== undefined && 109 + _onDragMove && 110 + _onDragEnd 111 + ) { 112 + const translateY = useSharedValue(0); 113 + const isDragging = useSharedValue(false); 114 + const ITEM_HEIGHT = 60; 115 + 116 + const panGesture = Gesture.Pan() 117 + .onStart(() => { 118 + isDragging.value = true; 119 + }) 120 + .onUpdate((event) => { 121 + translateY.value = event.translationY; 122 + 123 + const newIndex = Math.round( 124 + _dragIndex + translateY.value / ITEM_HEIGHT, 125 + ); 126 + const clampedIndex = Math.max( 127 + 0, 128 + Math.min(_dragTotalItems - 1, newIndex), 129 + ); 130 + 131 + if (clampedIndex !== _dragIndex) { 132 + runOnJS(_onDragMove)(_dragIndex, clampedIndex); 133 + } 134 + }) 135 + .onEnd(() => { 136 + const newIndex = Math.round( 137 + _dragIndex + translateY.value / ITEM_HEIGHT, 138 + ); 139 + const clampedIndex = Math.max( 140 + 0, 141 + Math.min(_dragTotalItems - 1, newIndex), 142 + ); 143 + 144 + runOnJS(_onDragEnd)(_dragIndex, clampedIndex); 145 + 146 + translateY.value = withSpring(0); 147 + isDragging.value = false; 148 + }); 149 + 150 + const animatedStyle = useAnimatedStyle(() => ({ 151 + transform: [{ translateY: translateY.value }], 152 + zIndex: isDragging.value ? 100 : 1, 153 + opacity: isDragging.value ? 0.8 : 1, 154 + })); 155 + 156 + return ( 157 + <Animated.View style={animatedStyle}> 158 + <View 159 + ref={ref} 160 + style={[ 161 + a.layout.flex.row, 162 + a.layout.flex.alignCenter, 163 + a.radius.all.sm, 164 + py[1], 165 + pl[3], 166 + pr[2], 167 + disabled && { opacity: 0.5 }, 168 + style, 169 + ]} 170 + > 171 + {dragHandle && ( 172 + <GestureDetector gesture={panGesture}> 173 + <View style={{ marginRight: 8 }}>{dragHandle}</View> 174 + </GestureDetector> 175 + )} 176 + {typeof children === "string" ? ( 177 + <Text style={{ color: theme.colors.popoverForeground }}> 178 + {children} 179 + </Text> 180 + ) : ( 181 + children 182 + )} 183 + </View> 184 + </Animated.View> 185 + ); 186 + } 187 + 72 188 return ( 73 189 <View 74 190 ref={ref} ··· 169 285 ); 170 286 }, 171 287 ); 288 + 289 + export interface MenuDraggableGroupProps { 290 + children: ReactNode; 291 + onMove: (fromIndex: number, toIndex: number) => void; 292 + onDragEnd: (fromIndex: number, toIndex: number) => void; 293 + dragHandle?: ReactNode; 294 + style?: ViewStyle; 295 + } 296 + 297 + export const MenuDraggableGroup = forwardRef<View, MenuDraggableGroupProps>( 298 + ({ children, onMove, onDragEnd, dragHandle, style }, ref) => { 299 + const { theme } = useTheme(); 300 + 301 + const childrenArray = Children.toArray(children); 302 + const draggableItems = childrenArray.filter( 303 + (child) => 304 + isValidElement(child) && 305 + (child.type === MenuItem || child.type === MenuSeparator), 306 + ); 307 + 308 + let itemIndex = 0; 309 + const enhancedChildren = Children.map(children, (child) => { 310 + if (isValidElement(child)) { 311 + if (child.type === MenuItem) { 312 + const currentIndex = itemIndex; 313 + itemIndex++; 314 + 315 + return cloneElement(child, { 316 + draggable: true, 317 + dragHandle: dragHandle || child.props.dragHandle, 318 + _dragIndex: currentIndex, 319 + _dragTotalItems: draggableItems.filter( 320 + (c) => isValidElement(c) && c.type === MenuItem, 321 + ).length, 322 + _onDragMove: onMove, 323 + _onDragEnd: onDragEnd, 324 + } as any); 325 + } 326 + if (child.type === MenuSeparator) { 327 + return child; 328 + } 329 + } 330 + return child; 331 + }); 332 + 333 + return ( 334 + <View 335 + ref={ref} 336 + style={[ 337 + { backgroundColor: theme.colors.muted + "c0" }, 338 + Platform.OS === "web" ? [px[1], py[1]] : p[1], 339 + gap.all[1], 340 + { borderRadius: borderRadius.lg }, 341 + style, 342 + ]} 343 + > 344 + {enhancedChildren} 345 + </View> 346 + ); 347 + }, 348 + );
+1 -1
js/components/src/lib/theme/tokens.ts
··· 595 595 xs: { 596 596 fontSize: 12, 597 597 lineHeight: 16, 598 - marginBottom: -0.7, 598 + marginBottom: -0.3, 599 599 fontWeight: "400" as const, 600 600 fontFamily: "AtkinsonHyperlegibleNext-Regular", 601 601 },
+5 -1
js/components/src/livestream-provider/index.tsx
··· 5 5 export function LivestreamProvider({ 6 6 children, 7 7 src, 8 + ignoreOuterContext = false, 8 9 }: { 9 10 children: React.ReactNode; 10 11 src: string; 12 + ignoreOuterContext?: boolean; 11 13 }) { 12 14 const context = useContext(LivestreamContext); 13 15 const store = useRef(makeLivestreamStore()).current; ··· 15 17 // this is ok, there's use cases for having one in another 16 18 // like having a player component that's independently usable 17 19 // but can also be embedded within an entire livestream page 18 - return <>{children}</>; 20 + if (!ignoreOuterContext) { 21 + return <>{children}</>; 22 + } 19 23 } 20 24 (window as any).livestreamStore = store; 21 25 return (
+15 -1
js/components/src/livestream-provider/websocket.tsx
··· 12 12 13 13 const ref = useRef<any[]>([]); 14 14 const handle = useRef<NodeJS.Timeout | null>(null); 15 + const hasReceivedMessage = useRef(false); 16 + const hasErrored = useRef(false); 15 17 16 18 const { readyState } = useWebSocket(`${wsUrl}/api/websocket/${src}`, { 17 19 reconnectInterval: 1000, 18 - shouldReconnect: () => true, 20 + shouldReconnect: () => !hasErrored.current, 19 21 20 22 onOpen: () => { 21 23 ref.current = []; 24 + hasReceivedMessage.current = false; 22 25 }, 23 26 24 27 onError: (e) => { 25 28 console.log("onError", e); 29 + if (!hasReceivedMessage.current) { 30 + hasErrored.current = true; 31 + handleWebSocketMessages([ 32 + { 33 + $type: "place.stream.error", 34 + code: "user_not_found", 35 + message: "this stream doesn't exist or is unavailable", 36 + }, 37 + ]); 38 + } 26 39 }, 27 40 28 41 // spamming the redux store with messages causes a zillion re-renders, ··· 30 43 onMessage: (msg) => { 31 44 try { 32 45 const data = JSON.parse(msg.data); 46 + hasReceivedMessage.current = true; 33 47 ref.current.push(data); 34 48 if (handle.current) { 35 49 return;
+2
js/components/src/livestream-store/livestream-state.tsx
··· 21 21 replyToMessage: ChatMessageViewHydrated | null; 22 22 streamKey: string | null; 23 23 setStreamKey: (key: string | null) => void; 24 + websocketConnected: boolean; 25 + hasReceivedSegment: boolean; 24 26 } 25 27 26 28 export interface LivestreamProblem {
+5
js/components/src/livestream-store/livestream-store.tsx
··· 22 22 authors: {}, 23 23 recentSegments: [], 24 24 problems: [], 25 + websocketConnected: false, 26 + hasReceivedSegment: false, 25 27 })); 26 28 }; 27 29 ··· 58 60 export const useLivestream = () => useLivestreamStore((x) => x.livestream); 59 61 60 62 export const useSegment = () => useLivestreamStore((x) => x.segment); 63 + 64 + export const useRecentSegments = () => 65 + useLivestreamStore((x) => x.recentSegments); 61 66 62 67 export const useRenditions = () => useLivestreamStore((x) => x.renditions);
+95 -73
js/components/src/livestream-store/websocket-consumer.tsx
··· 21 21 messages: any[], 22 22 ): LivestreamState => { 23 23 for (let message of messages) { 24 - if (PlaceStreamLivestream.isLivestreamView(message)) { 25 - const newLivestream = message as LivestreamViewHydrated; 26 - const oldLivestream = state.livestream; 27 - 28 - // check if this is actually new 29 - if (!oldLivestream || oldLivestream.uri !== newLivestream.uri) { 30 - const streamTitle = newLivestream.record.title || "something cool!"; 31 - const systemMessage = SystemMessages.streamStart(streamTitle); 32 - // set proper times 33 - systemMessage.indexedAt = newLivestream.indexedAt; 34 - systemMessage.record.createdAt = newLivestream.record.createdAt; 35 - 36 - state = reduceChat(state, [systemMessage], []); 37 - } 38 - 39 - state = { 40 - ...state, 41 - livestream: newLivestream, 42 - }; 43 - } else if (PlaceStreamLivestream.isViewerCount(message)) { 44 - message = message as PlaceStreamLivestream.ViewerCount; 45 - state = { 46 - ...state, 47 - viewers: message.count, 48 - }; 49 - } else if (PlaceStreamChatDefs.isMessageView(message)) { 50 - message = message as PlaceStreamChatDefs.MessageView; 51 - // Explicitly map MessageView to MessageViewHydrated 52 - const hydrated: ChatMessageViewHydrated = { 53 - uri: message.uri, 54 - cid: message.cid, 55 - author: message.author, 56 - record: message.record as PlaceStreamChatMessage.Record, 57 - indexedAt: message.indexedAt, 58 - chatProfile: (message as any).chatProfile, 59 - replyTo: (message as any).replyTo, 60 - deleted: message.deleted, 61 - }; 62 - state = reduceChat(state, [hydrated], [], []); 63 - } else if (PlaceStreamSegment.isRecord(message)) { 64 - const newRecentSegments = [...state.recentSegments]; 65 - newRecentSegments.unshift(message); 66 - if (newRecentSegments.length > MAX_RECENT_SEGMENTS) { 67 - newRecentSegments.pop(); 68 - } 24 + if (message.$type === "place.stream.error") { 69 25 state = { 70 26 ...state, 71 - segment: message as PlaceStreamSegment.Record, 72 - recentSegments: newRecentSegments, 73 - problems: findProblems(newRecentSegments), 74 - }; 75 - } else if (PlaceStreamDefs.isBlockView(message)) { 76 - const block = message as PlaceStreamDefs.BlockView; 77 - state = reduceChat(state, [], [block], []); 78 - } else if (PlaceStreamDefs.isRenditions(message)) { 79 - message = message as PlaceStreamDefs.Renditions; 80 - state = { 81 - ...state, 82 - renditions: message.renditions, 83 - }; 84 - } else if (AppBskyActorDefs.isProfileViewBasic(message)) { 85 - state = { 86 - ...state, 87 - profile: message, 27 + problems: [ 28 + ...state.problems, 29 + { 30 + code: message.code, 31 + message: message.message, 32 + severity: "error", 33 + }, 34 + ], 88 35 }; 89 - } else if (PlaceStreamChatGate.isRecord(message)) { 90 - const hideRecord = message as PlaceStreamChatGate.Record; 91 - const hiddenMessageUri = hideRecord.hiddenMessage; 92 - const newPendingHides = [...state.pendingHides]; 93 - if (!newPendingHides.includes(hiddenMessageUri)) { 94 - newPendingHides.push(hiddenMessageUri); 36 + } else { 37 + if (!state.websocketConnected) { 38 + state = { 39 + ...state, 40 + websocketConnected: true, 41 + }; 95 42 } 96 43 97 - state = { 98 - ...state, 99 - pendingHides: newPendingHides, 100 - }; 101 - state = reduceChat(state, [], [], [hiddenMessageUri]); 44 + if (PlaceStreamLivestream.isLivestreamView(message)) { 45 + const newLivestream = message as LivestreamViewHydrated; 46 + const oldLivestream = state.livestream; 47 + 48 + // check if this is actually new 49 + if (!oldLivestream || oldLivestream.uri !== newLivestream.uri) { 50 + const streamTitle = newLivestream.record.title || "something cool!"; 51 + const systemMessage = SystemMessages.streamStart(streamTitle); 52 + // set proper times 53 + systemMessage.indexedAt = newLivestream.indexedAt; 54 + systemMessage.record.createdAt = newLivestream.record.createdAt; 55 + 56 + state = reduceChat(state, [systemMessage], []); 57 + } 58 + 59 + state = { 60 + ...state, 61 + livestream: newLivestream, 62 + }; 63 + } else if (PlaceStreamLivestream.isViewerCount(message)) { 64 + message = message as PlaceStreamLivestream.ViewerCount; 65 + state = { 66 + ...state, 67 + viewers: message.count, 68 + }; 69 + } else if (PlaceStreamChatDefs.isMessageView(message)) { 70 + message = message as PlaceStreamChatDefs.MessageView; 71 + // Explicitly map MessageView to MessageViewHydrated 72 + const hydrated: ChatMessageViewHydrated = { 73 + uri: message.uri, 74 + cid: message.cid, 75 + author: message.author, 76 + record: message.record as PlaceStreamChatMessage.Record, 77 + indexedAt: message.indexedAt, 78 + chatProfile: (message as any).chatProfile, 79 + replyTo: (message as any).replyTo, 80 + deleted: message.deleted, 81 + }; 82 + state = reduceChat(state, [hydrated], [], []); 83 + } else if (PlaceStreamSegment.isRecord(message)) { 84 + const newRecentSegments = [...state.recentSegments]; 85 + newRecentSegments.unshift(message); 86 + if (newRecentSegments.length > MAX_RECENT_SEGMENTS) { 87 + newRecentSegments.pop(); 88 + } 89 + state = { 90 + ...state, 91 + segment: message as PlaceStreamSegment.Record, 92 + recentSegments: newRecentSegments, 93 + problems: findProblems(newRecentSegments), 94 + hasReceivedSegment: true, 95 + }; 96 + } else if (PlaceStreamDefs.isBlockView(message)) { 97 + const block = message as PlaceStreamDefs.BlockView; 98 + state = reduceChat(state, [], [block], []); 99 + } else if (PlaceStreamDefs.isRenditions(message)) { 100 + message = message as PlaceStreamDefs.Renditions; 101 + state = { 102 + ...state, 103 + renditions: message.renditions, 104 + }; 105 + } else if (AppBskyActorDefs.isProfileViewBasic(message)) { 106 + state = { 107 + ...state, 108 + profile: message, 109 + }; 110 + } else if (PlaceStreamChatGate.isRecord(message)) { 111 + const hideRecord = message as PlaceStreamChatGate.Record; 112 + const hiddenMessageUri = hideRecord.hiddenMessage; 113 + const newPendingHides = [...state.pendingHides]; 114 + if (!newPendingHides.includes(hiddenMessageUri)) { 115 + newPendingHides.push(hiddenMessageUri); 116 + } 117 + 118 + state = { 119 + ...state, 120 + pendingHides: newPendingHides, 121 + }; 122 + state = reduceChat(state, [], [], [hiddenMessageUri]); 123 + } 102 124 } 103 125 } 104 126 return reduceChat(state, [], [], []);
+115
js/docs/src/content/docs/lex-reference/live/place-stream-live-getrecommendations.md
··· 1 + --- 2 + title: place.stream.live.getRecommendations 3 + description: Reference for the place.stream.live.getRecommendations lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="main"></a> 11 + 12 + ### `main` 13 + 14 + **Type:** `query` 15 + 16 + Get the list of streamers recommended by a user 17 + 18 + **Parameters:** 19 + 20 + | Name | Type | Req'd | Description | Constraints | 21 + | --------- | -------- | ----- | -------------------------------------------------- | ------------- | 22 + | `userDID` | `string` | ✅ | The DID of the user whose recommendations to fetch | Format: `did` | 23 + 24 + **Output:** 25 + 26 + - **Encoding:** `application/json` 27 + - **Schema:** 28 + 29 + **Schema Type:** `object` 30 + 31 + | Name | Type | Req'd | Description | Constraints | 32 + | ----------------- | ------------------------------------------------------------------------------------------- | ----- | --------------------------------------- | ------------- | 33 + | `recommendations` | Array of Union of:<br/>&nbsp;&nbsp;[`#livestreamRecommendation`](#livestreamrecommendation) | ✅ | Ordered list of recommendations | | 34 + | `userDID` | `string` | ❌ | The user DID this recommendation is for | Format: `did` | 35 + 36 + --- 37 + 38 + <a name="livestreamrecommendation"></a> 39 + 40 + ### `livestreamRecommendation` 41 + 42 + **Type:** `object` 43 + 44 + **Properties:** 45 + 46 + | Name | Type | Req'd | Description | Constraints | 47 + | -------- | -------- | ----- | ----------------------------------- | ----------------------------------- | 48 + | `did` | `string` | ✅ | The DID of the recommended streamer | Format: `did` | 49 + | `source` | `string` | ✅ | Source of the recommendation | Enum: `streamer`, `follows`, `host` | 50 + 51 + --- 52 + 53 + ## Lexicon Source 54 + 55 + ```json 56 + { 57 + "lexicon": 1, 58 + "id": "place.stream.live.getRecommendations", 59 + "defs": { 60 + "main": { 61 + "type": "query", 62 + "description": "Get the list of streamers recommended by a user", 63 + "parameters": { 64 + "type": "params", 65 + "required": ["userDID"], 66 + "properties": { 67 + "userDID": { 68 + "type": "string", 69 + "format": "did", 70 + "description": "The DID of the user whose recommendations to fetch" 71 + } 72 + } 73 + }, 74 + "output": { 75 + "encoding": "application/json", 76 + "schema": { 77 + "type": "object", 78 + "required": ["recommendations"], 79 + "properties": { 80 + "recommendations": { 81 + "type": "array", 82 + "description": "Ordered list of recommendations", 83 + "items": { 84 + "type": "union", 85 + "refs": ["#livestreamRecommendation"] 86 + } 87 + }, 88 + "userDID": { 89 + "type": "string", 90 + "format": "did", 91 + "description": "The user DID this recommendation is for" 92 + } 93 + } 94 + } 95 + } 96 + }, 97 + "livestreamRecommendation": { 98 + "type": "object", 99 + "required": ["did", "source"], 100 + "properties": { 101 + "did": { 102 + "type": "string", 103 + "format": "did", 104 + "description": "The DID of the recommended streamer" 105 + }, 106 + "source": { 107 + "type": "string", 108 + "enum": ["streamer", "follows", "host"], 109 + "description": "Source of the recommendation" 110 + } 111 + } 112 + } 113 + } 114 + } 115 + ```
+64
js/docs/src/content/docs/lex-reference/live/place-stream-live-recommendations.md
··· 1 + --- 2 + title: place.stream.live.recommendations 3 + description: Reference for the place.stream.live.recommendations lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="main"></a> 11 + 12 + ### `main` 13 + 14 + **Type:** `record` 15 + 16 + A list of recommended streamers, in order of preference 17 + 18 + **Record Key:** `literal:self` 19 + 20 + **Record Properties:** 21 + 22 + | Name | Type | Req'd | Description | Constraints | 23 + | ----------- | ----------------- | ----- | ----------------------------------------------------- | ----------------------------- | 24 + | `streamers` | Array of `string` | ✅ | Ordered list of recommended streamer DIDs | Min Items: 0<br/>Max Items: 8 | 25 + | `createdAt` | `string` | ✅ | Client-declared timestamp when this list was created. | Format: `datetime` | 26 + 27 + --- 28 + 29 + ## Lexicon Source 30 + 31 + ```json 32 + { 33 + "lexicon": 1, 34 + "id": "place.stream.live.recommendations", 35 + "defs": { 36 + "main": { 37 + "type": "record", 38 + "description": "A list of recommended streamers, in order of preference", 39 + "key": "literal:self", 40 + "record": { 41 + "type": "object", 42 + "required": ["streamers", "createdAt"], 43 + "properties": { 44 + "streamers": { 45 + "type": "array", 46 + "description": "Ordered list of recommended streamer DIDs", 47 + "items": { 48 + "type": "string", 49 + "format": "did" 50 + }, 51 + "maxLength": 8, 52 + "minLength": 0 53 + }, 54 + "createdAt": { 55 + "type": "string", 56 + "format": "datetime", 57 + "description": "Client-declared timestamp when this list was created." 58 + } 59 + } 60 + } 61 + } 62 + } 63 + } 64 + ```
+114
js/docs/src/content/docs/lex-reference/live/place-stream-live-searchactorstypeahead.md
··· 1 + --- 2 + title: place.stream.live.searchActorsTypeahead 3 + description: Reference for the place.stream.live.searchActorsTypeahead lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="main"></a> 11 + 12 + ### `main` 13 + 14 + **Type:** `query` 15 + 16 + Find actor suggestions for a prefix search term. Expected use is for 17 + auto-completion during text field entry. 18 + 19 + **Parameters:** 20 + 21 + | Name | Type | Req'd | Description | Constraints | 22 + | ------- | --------- | ----- | --------------------------------------------- | ------------------------------------- | 23 + | `q` | `string` | ❌ | Search query prefix; not a full query string. | | 24 + | `limit` | `integer` | ❌ | | Min: 1<br/>Max: 100<br/>Default: `10` | 25 + 26 + **Output:** 27 + 28 + - **Encoding:** `application/json` 29 + - **Schema:** 30 + 31 + **Schema Type:** `object` 32 + 33 + | Name | Type | Req'd | Description | Constraints | 34 + | -------- | --------------------------- | ----- | ----------- | ----------- | 35 + | `actors` | Array of [`#actor`](#actor) | ✅ | | | 36 + 37 + --- 38 + 39 + <a name="actor"></a> 40 + 41 + ### `actor` 42 + 43 + **Type:** `object` 44 + 45 + **Properties:** 46 + 47 + | Name | Type | Req'd | Description | Constraints | 48 + | -------- | -------- | ----- | ------------------ | ---------------- | 49 + | `did` | `string` | ✅ | The actor's DID | Format: `did` | 50 + | `handle` | `string` | ✅ | The actor's handle | Format: `handle` | 51 + 52 + --- 53 + 54 + ## Lexicon Source 55 + 56 + ```json 57 + { 58 + "lexicon": 1, 59 + "id": "place.stream.live.searchActorsTypeahead", 60 + "defs": { 61 + "main": { 62 + "type": "query", 63 + "description": "Find actor suggestions for a prefix search term. Expected use is for auto-completion during text field entry.", 64 + "parameters": { 65 + "type": "params", 66 + "properties": { 67 + "q": { 68 + "type": "string", 69 + "description": "Search query prefix; not a full query string." 70 + }, 71 + "limit": { 72 + "type": "integer", 73 + "minimum": 1, 74 + "maximum": 100, 75 + "default": 10 76 + } 77 + } 78 + }, 79 + "output": { 80 + "encoding": "application/json", 81 + "schema": { 82 + "type": "object", 83 + "required": ["actors"], 84 + "properties": { 85 + "actors": { 86 + "type": "array", 87 + "items": { 88 + "type": "ref", 89 + "ref": "#actor" 90 + } 91 + } 92 + } 93 + } 94 + } 95 + }, 96 + "actor": { 97 + "type": "object", 98 + "required": ["did", "handle"], 99 + "properties": { 100 + "did": { 101 + "type": "string", 102 + "format": "did", 103 + "description": "The actor's DID" 104 + }, 105 + "handle": { 106 + "type": "string", 107 + "format": "handle", 108 + "description": "The actor's handle" 109 + } 110 + } 111 + } 112 + } 113 + } 114 + ```
+134
js/docs/src/content/docs/lex-reference/openapi.json
··· 619 619 ] 620 620 } 621 621 }, 622 + "/xrpc/place.stream.live.getRecommendations": { 623 + "get": { 624 + "summary": "Get the list of streamers recommended by a user", 625 + "operationId": "place.stream.live.getRecommendations", 626 + "tags": ["place.stream.live"], 627 + "responses": { 628 + "200": { 629 + "description": "Success", 630 + "content": { 631 + "application/json": { 632 + "schema": { 633 + "type": "object", 634 + "properties": { 635 + "recommendations": { 636 + "type": "array", 637 + "description": "Ordered list of recommendations", 638 + "items": { 639 + "oneOf": [ 640 + { 641 + "$ref": "#/components/schemas/place.stream.live.getRecommendations_livestreamRecommendation" 642 + } 643 + ] 644 + } 645 + }, 646 + "userDID": { 647 + "type": "string", 648 + "description": "The user DID this recommendation is for", 649 + "format": "did" 650 + } 651 + }, 652 + "required": ["recommendations"] 653 + } 654 + } 655 + } 656 + } 657 + }, 658 + "parameters": [ 659 + { 660 + "name": "userDID", 661 + "in": "query", 662 + "required": true, 663 + "description": "The DID of the user whose recommendations to fetch", 664 + "schema": { 665 + "type": "string", 666 + "description": "The DID of the user whose recommendations to fetch", 667 + "format": "did" 668 + } 669 + } 670 + ] 671 + } 672 + }, 622 673 "/xrpc/place.stream.live.getSegments": { 623 674 "get": { 624 675 "summary": "Get a list of livestream segments for a user", ··· 674 725 "schema": { 675 726 "type": "string", 676 727 "format": "date-time" 728 + } 729 + } 730 + ] 731 + } 732 + }, 733 + "/xrpc/place.stream.live.searchActorsTypeahead": { 734 + "get": { 735 + "summary": "Find actor suggestions for a prefix search term. Expected use is for auto-completion during text field entry.", 736 + "operationId": "place.stream.live.searchActorsTypeahead", 737 + "tags": ["place.stream.live"], 738 + "responses": { 739 + "200": { 740 + "description": "Success", 741 + "content": { 742 + "application/json": { 743 + "schema": { 744 + "type": "object", 745 + "properties": { 746 + "actors": { 747 + "type": "array", 748 + "items": { 749 + "$ref": "#/components/schemas/place.stream.live.searchActorsTypeahead_actor" 750 + } 751 + } 752 + }, 753 + "required": ["actors"] 754 + } 755 + } 756 + } 757 + } 758 + }, 759 + "parameters": [ 760 + { 761 + "name": "q", 762 + "in": "query", 763 + "required": false, 764 + "description": "Search query prefix; not a full query string.", 765 + "schema": { 766 + "type": "string", 767 + "description": "Search query prefix; not a full query string." 768 + } 769 + }, 770 + { 771 + "name": "limit", 772 + "in": "query", 773 + "required": false, 774 + "schema": { 775 + "type": "integer", 776 + "default": 10, 777 + "minimum": 1, 778 + "maximum": 100 677 779 } 678 780 } 679 781 ] ··· 2144 2246 }, 2145 2247 "required": ["count"] 2146 2248 }, 2249 + "place.stream.live.getRecommendations_livestreamRecommendation": { 2250 + "type": "object", 2251 + "properties": { 2252 + "did": { 2253 + "type": "string", 2254 + "description": "The DID of the recommended streamer", 2255 + "format": "did" 2256 + }, 2257 + "source": { 2258 + "type": "string", 2259 + "description": "Source of the recommendation", 2260 + "enum": ["streamer", "follows", "host"] 2261 + } 2262 + }, 2263 + "required": ["did", "source"] 2264 + }, 2147 2265 "place.stream.segment_segmentView": { 2148 2266 "type": "object", 2149 2267 "properties": { ··· 2154 2272 "record": {} 2155 2273 }, 2156 2274 "required": ["cid", "record"] 2275 + }, 2276 + "place.stream.live.searchActorsTypeahead_actor": { 2277 + "type": "object", 2278 + "properties": { 2279 + "did": { 2280 + "type": "string", 2281 + "description": "The actor's DID", 2282 + "format": "did" 2283 + }, 2284 + "handle": { 2285 + "type": "string", 2286 + "description": "The actor's handle", 2287 + "format": "handle" 2288 + } 2289 + }, 2290 + "required": ["did", "handle"] 2157 2291 }, 2158 2292 "place.stream.live.subscribeSegments_segment": { 2159 2293 "type": "string",
+59
lexicons/place/stream/live/getRecommendations.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.live.getRecommendations", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get the list of streamers recommended by a user", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["userDID"], 11 + "properties": { 12 + "userDID": { 13 + "type": "string", 14 + "format": "did", 15 + "description": "The DID of the user whose recommendations to fetch" 16 + } 17 + } 18 + }, 19 + "output": { 20 + "encoding": "application/json", 21 + "schema": { 22 + "type": "object", 23 + "required": ["recommendations"], 24 + "properties": { 25 + "recommendations": { 26 + "type": "array", 27 + "description": "Ordered list of recommendations", 28 + "items": { 29 + "type": "union", 30 + "refs": ["#livestreamRecommendation"] 31 + } 32 + }, 33 + "userDID": { 34 + "type": "string", 35 + "format": "did", 36 + "description": "The user DID this recommendation is for" 37 + } 38 + } 39 + } 40 + } 41 + }, 42 + "livestreamRecommendation": { 43 + "type": "object", 44 + "required": ["did", "source"], 45 + "properties": { 46 + "did": { 47 + "type": "string", 48 + "format": "did", 49 + "description": "The DID of the recommended streamer" 50 + }, 51 + "source": { 52 + "type": "string", 53 + "enum": ["streamer", "follows", "host"], 54 + "description": "Source of the recommendation" 55 + } 56 + } 57 + } 58 + } 59 + }
+32
lexicons/place/stream/live/recommendations.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.live.recommendations", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A list of recommended streamers, in order of preference", 8 + "key": "literal:self", 9 + "record": { 10 + "type": "object", 11 + "required": ["streamers", "createdAt"], 12 + "properties": { 13 + "streamers": { 14 + "type": "array", 15 + "description": "Ordered list of recommended streamer DIDs", 16 + "items": { 17 + "type": "string", 18 + "format": "did" 19 + }, 20 + "maxLength": 8, 21 + "minLength": 0 22 + }, 23 + "createdAt": { 24 + "type": "string", 25 + "format": "datetime", 26 + "description": "Client-declared timestamp when this list was created." 27 + } 28 + } 29 + } 30 + } 31 + } 32 + }
+57
lexicons/place/stream/live/searchActorsTypeahead.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.live.searchActorsTypeahead", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Find actor suggestions for a prefix search term. Expected use is for auto-completion during text field entry.", 8 + "parameters": { 9 + "type": "params", 10 + "properties": { 11 + "q": { 12 + "type": "string", 13 + "description": "Search query prefix; not a full query string." 14 + }, 15 + "limit": { 16 + "type": "integer", 17 + "minimum": 1, 18 + "maximum": 100, 19 + "default": 10 20 + } 21 + } 22 + }, 23 + "output": { 24 + "encoding": "application/json", 25 + "schema": { 26 + "type": "object", 27 + "required": ["actors"], 28 + "properties": { 29 + "actors": { 30 + "type": "array", 31 + "items": { 32 + "type": "ref", 33 + "ref": "#actor" 34 + } 35 + } 36 + } 37 + } 38 + } 39 + }, 40 + "actor": { 41 + "type": "object", 42 + "required": ["did", "handle"], 43 + "properties": { 44 + "did": { 45 + "type": "string", 46 + "format": "did", 47 + "description": "The actor's DID" 48 + }, 49 + "handle": { 50 + "type": "string", 51 + "format": "handle", 52 + "description": "The actor's handle" 53 + } 54 + } 55 + } 56 + } 57 + }
+1
pkg/atproto/firehose.go
··· 161 161 constants.APP_BSKY_GRAPH_FOLLOW, 162 162 constants.APP_BSKY_FEED_POST, 163 163 constants.APP_BSKY_GRAPH_BLOCK, 164 + constants.PLACE_STREAM_LIVE_RECOMMENDATIONS, 164 165 } 165 166 166 167 func (atsync *ATProtoSynchronizer) handleCommitEventOps(ctx context.Context, evt *comatproto.SyncSubscribeRepos_Commit) {
+33
pkg/atproto/sync.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "encoding/json" 5 6 "errors" 6 7 "fmt" 7 8 "reflect" ··· 421 422 err = atsync.Model.CreateMetadataConfiguration(ctx, metadata) 422 423 if err != nil { 423 424 log.Error(ctx, "failed to create metadata configuration", "err", err) 425 + } 426 + 427 + case *streamplace.LiveRecommendations: 428 + log.Debug(ctx, "creating recommendations", "userDID", userDID, "count", len(rec.Streamers)) 429 + 430 + // Validate max 8 streamers 431 + if len(rec.Streamers) > 8 { 432 + log.Warn(ctx, "recommendations exceed maximum of 8", "count", len(rec.Streamers)) 433 + return fmt.Errorf("maximum 8 recommendations allowed, got %d", len(rec.Streamers)) 434 + } 435 + 436 + // Marshal streamers to JSON 437 + streamersJSON, err := json.Marshal(rec.Streamers) 438 + if err != nil { 439 + return fmt.Errorf("failed to marshal streamers: %w", err) 440 + } 441 + 442 + // Parse createdAt timestamp 443 + createdAt, err := time.Parse(time.RFC3339, rec.CreatedAt) 444 + if err != nil { 445 + return fmt.Errorf("failed to parse createdAt: %w", err) 446 + } 447 + 448 + recommendation := &model.Recommendation{ 449 + UserDID: userDID, 450 + Streamers: json.RawMessage(streamersJSON), 451 + CreatedAt: createdAt, 452 + } 453 + 454 + err = atsync.Model.UpsertRecommendation(recommendation) 455 + if err != nil { 456 + return fmt.Errorf("failed to upsert recommendation: %w", err) 424 457 } 425 458 426 459 default:
+88 -85
pkg/config/config.go
··· 52 52 } 53 53 54 54 type CLI struct { 55 - AdminAccount string 56 - Build *BuildFlags 57 - DataDir string 58 - DBURL string 59 - EthAccountAddr string 60 - EthKeystorePath string 61 - EthPassword string 62 - FirebaseServiceAccount string 63 - FirebaseServiceAccountFile string 64 - GitLabURL string 65 - HTTPAddr string 66 - HTTPInternalAddr string 67 - HTTPSAddr string 68 - RTMPAddr string 69 - RTMPSAddr string 70 - Secure bool 71 - NoMist bool 72 - MistAdminPort int 73 - MistHTTPPort int 74 - MistRTMPPort int 75 - SigningKeyPath string 76 - TAURL string 77 - TLSCertPath string 78 - TLSKeyPath string 79 - PKCS11ModulePath string 80 - PKCS11Pin string 81 - PKCS11TokenSlot string 82 - PKCS11TokenLabel string 83 - PKCS11TokenSerial string 84 - PKCS11KeypairLabel string 85 - PKCS11KeypairID string 86 - StreamerName string 87 - RelayHost string 88 - Debug map[string]map[string]int 89 - AllowedStreams []string 90 - WideOpen bool 91 - Peers []string 92 - Redirects []string 93 - TestStream bool 94 - FrontendProxy string 95 - PublicOAuth bool 96 - AppBundleID string 97 - NoFirehose bool 98 - PrintChat bool 99 - Color string 100 - LivepeerGatewayURL string 101 - LivepeerGateway bool 102 - WHIPTest string 103 - Thumbnail bool 104 - SmearAudio bool 105 - ExternalSigning bool 106 - RTMPServerAddon string 107 - TracingEndpoint string 108 - BroadcasterHost string 109 - XXDeprecatedPublicHost string 110 - ServerHost string 111 - RateLimitPerSecond int 112 - RateLimitBurst int 113 - RateLimitWebsocket int 114 - JWK jwk.Key 115 - AccessJWK jwk.Key 116 - dataDirFlags []*string 117 - DiscordWebhooks []*discordtypes.Webhook 118 - NewWebRTCPlayback bool 119 - AppleTeamID string 120 - AndroidCertFingerprint string 121 - Labelers []string 122 - AtprotoDID string 123 - LivepeerHelp bool 124 - PLCURL string 125 - ContentFilters *ContentFilters 126 - SQLLogging bool 127 - SentryDSN string 128 - LivepeerDebug bool 129 - Tickets []string 130 - IrohTopic string 131 - DID string 132 - DisableIrohRelay bool 133 - DevAccountCreds map[string]string 134 - StreamSessionTimeout time.Duration 135 - Replicators []string 136 - WebsocketURL string 137 - BehindHTTPSProxy bool 138 - SegmentDebugDir string 139 - Syndicate []string 55 + AdminAccount string 56 + Build *BuildFlags 57 + DataDir string 58 + DBURL string 59 + EthAccountAddr string 60 + EthKeystorePath string 61 + EthPassword string 62 + FirebaseServiceAccount string 63 + FirebaseServiceAccountFile string 64 + GitLabURL string 65 + HTTPAddr string 66 + HTTPInternalAddr string 67 + HTTPSAddr string 68 + RTMPAddr string 69 + RTMPSAddr string 70 + Secure bool 71 + NoMist bool 72 + MistAdminPort int 73 + MistHTTPPort int 74 + MistRTMPPort int 75 + SigningKeyPath string 76 + TAURL string 77 + TLSCertPath string 78 + TLSKeyPath string 79 + PKCS11ModulePath string 80 + PKCS11Pin string 81 + PKCS11TokenSlot string 82 + PKCS11TokenLabel string 83 + PKCS11TokenSerial string 84 + PKCS11KeypairLabel string 85 + PKCS11KeypairID string 86 + StreamerName string 87 + RelayHost string 88 + Debug map[string]map[string]int 89 + AllowedStreams []string 90 + WideOpen bool 91 + Peers []string 92 + Redirects []string 93 + TestStream bool 94 + FrontendProxy string 95 + PublicOAuth bool 96 + AppBundleID string 97 + NoFirehose bool 98 + PrintChat bool 99 + Color string 100 + LivepeerGatewayURL string 101 + LivepeerGateway bool 102 + WHIPTest string 103 + Thumbnail bool 104 + SmearAudio bool 105 + ExternalSigning bool 106 + RTMPServerAddon string 107 + TracingEndpoint string 108 + BroadcasterHost string 109 + XXDeprecatedPublicHost string 110 + ServerHost string 111 + RateLimitPerSecond int 112 + RateLimitBurst int 113 + RateLimitWebsocket int 114 + JWK jwk.Key 115 + AccessJWK jwk.Key 116 + dataDirFlags []*string 117 + DiscordWebhooks []*discordtypes.Webhook 118 + NewWebRTCPlayback bool 119 + AppleTeamID string 120 + AndroidCertFingerprint string 121 + Labelers []string 122 + AtprotoDID string 123 + LivepeerHelp bool 124 + PLCURL string 125 + ContentFilters *ContentFilters 126 + DefaultRecommendedStreamers []string 127 + SQLLogging bool 128 + SentryDSN string 129 + LivepeerDebug bool 130 + Tickets []string 131 + IrohTopic string 132 + DID string 133 + DisableIrohRelay bool 134 + DevAccountCreds map[string]string 135 + StreamSessionTimeout time.Duration 136 + Replicators []string 137 + WebsocketURL string 138 + BehindHTTPSProxy bool 139 + SegmentDebugDir string 140 + Syndicate []string 140 141 } 141 142 142 143 // ContentFilters represents the content filtering configuration ··· 204 205 fs.StringVar(&cli.ServerHost, "server-host", "", "public host for this particular physical streamplace node. defaults to broadcaster-host and only must be set for multi-node broadcasters") 205 206 fs.BoolVar(&cli.Thumbnail, "thumbnail", true, "enable thumbnail generation") 206 207 fs.BoolVar(&cli.SmearAudio, "smear-audio", false, "enable audio smearing to create 'perfect' segment timestamps") 208 + 207 209 fs.StringVar(&cli.TracingEndpoint, "tracing-endpoint", "", "gRPC endpoint to send traces to") 208 210 fs.IntVar(&cli.RateLimitPerSecond, "rate-limit-per-second", 0, "rate limit for requests per second per ip") 209 211 fs.IntVar(&cli.RateLimitBurst, "rate-limit-burst", 0, "rate limit burst for requests per ip") ··· 218 220 cli.StringSliceFlag(fs, &cli.Labelers, "labelers", []string{}, "did of labelers that this instance should subscribe to") 219 221 fs.StringVar(&cli.AtprotoDID, "atproto-did", "", "atproto did to respond to on /.well-known/atproto-did (default did:web:PUBLIC_HOST)") 220 222 cli.JSONFlag(fs, &cli.ContentFilters, "content-filters", "{}", "JSON content filtering rules") 223 + cli.StringSliceFlag(fs, &cli.DefaultRecommendedStreamers, "default-recommended-streamers", []string{}, "comma-separated list of streamer DIDs to recommend by default when no other recommendations are available") 221 224 fs.BoolVar(&cli.LivepeerHelp, "livepeer-help", false, "print help for livepeer flags and exit") 222 225 fs.StringVar(&cli.PLCURL, "plc-url", "https://plc.directory", "url of the plc directory") 223 226 fs.BoolVar(&cli.SQLLogging, "sql-logging", false, "enable sql logging")
+12 -11
pkg/constants/constants.go
··· 1 1 package constants 2 2 3 - var PLACE_STREAM_KEY = "place.stream.key" //nolint:all 4 - var PLACE_STREAM_LIVESTREAM = "place.stream.livestream" //nolint:all 5 - var PLACE_STREAM_CHAT_MESSAGE = "place.stream.chat.message" //nolint:all 6 - var PLACE_STREAM_CHAT_PROFILE = "place.stream.chat.profile" //nolint:all 7 - var PLACE_STREAM_SERVER_SETTINGS = "place.stream.server.settings" //nolint:all 8 - var STREAMPLACE_SIGNING_KEY = "signingKey" //nolint:all 9 - var APP_BSKY_GRAPH_FOLLOW = "app.bsky.graph.follow" //nolint:all 10 - var APP_BSKY_FEED_POST = "app.bsky.feed.post" //nolint:all 11 - var APP_BSKY_GRAPH_BLOCK = "app.bsky.graph.block" //nolint:all 12 - var PLACE_STREAM_CHAT_GATE = "place.stream.chat.gate" //nolint:all 13 - var PLACE_STREAM_DEFAULT_METADATA = "place.stream.metadata.configuration" //nolint:all 3 + var PLACE_STREAM_KEY = "place.stream.key" //nolint:all 4 + var PLACE_STREAM_LIVESTREAM = "place.stream.livestream" //nolint:all 5 + var PLACE_STREAM_CHAT_MESSAGE = "place.stream.chat.message" //nolint:all 6 + var PLACE_STREAM_CHAT_PROFILE = "place.stream.chat.profile" //nolint:all 7 + var PLACE_STREAM_SERVER_SETTINGS = "place.stream.server.settings" //nolint:all 8 + var STREAMPLACE_SIGNING_KEY = "signingKey" //nolint:all 9 + var APP_BSKY_GRAPH_FOLLOW = "app.bsky.graph.follow" //nolint:all 10 + var APP_BSKY_FEED_POST = "app.bsky.feed.post" //nolint:all 11 + var APP_BSKY_GRAPH_BLOCK = "app.bsky.graph.block" //nolint:all 12 + var PLACE_STREAM_CHAT_GATE = "place.stream.chat.gate" //nolint:all 13 + var PLACE_STREAM_DEFAULT_METADATA = "place.stream.metadata.configuration" //nolint:all 14 + var PLACE_STREAM_LIVE_RECOMMENDATIONS = "place.stream.live.recommendations" //nolint:all 14 15 15 16 const DID_KEY_PREFIX = "did:key" //nolint:all 16 17 const ADDRESS_KEY_PREFIX = "0x" //nolint:all
+1
pkg/gen/gen.go
··· 32 32 streamplace.MetadataDistributionPolicy{}, 33 33 streamplace.MetadataContentRights{}, 34 34 streamplace.MetadataContentWarnings{}, 35 + streamplace.LiveRecommendations{}, 35 36 ); err != nil { 36 37 panic(err) 37 38 }
+6
pkg/model/model.go
··· 32 32 MostRecentSegments() ([]Segment, error) 33 33 LatestSegmentForUser(user string) (*Segment, error) 34 34 LatestSegmentsForUser(user string, limit int, before *time.Time, after *time.Time) ([]Segment, error) 35 + FilterLiveRepoDIDs(repoDIDs []string) ([]string, error) 35 36 CreateThumbnail(thumb *Thumbnail) error 36 37 LatestThumbnailForUser(user string) (*Thumbnail, error) 37 38 GetSegment(id string) (*Segment, error) ··· 48 49 GetRepoByHandleOrDID(arg string) (*Repo, error) 49 50 GetRepoBySigningKey(signingKey string) (*Repo, error) 50 51 GetAllRepos() ([]Repo, error) 52 + SearchReposByHandle(query string, limit int) ([]Repo, error) 51 53 UpdateRepo(repo *Repo) error 52 54 53 55 UpdateSigningKey(key *SigningKey) error ··· 107 109 CreateMetadataConfiguration(ctx context.Context, metadata *MetadataConfiguration) error 108 110 GetMetadataConfiguration(ctx context.Context, repoDID string) (*MetadataConfiguration, error) 109 111 DeleteMetadataConfiguration(ctx context.Context, repoDID string) error 112 + 113 + GetRecommendation(userDID string) (*Recommendation, error) 114 + UpsertRecommendation(rec *Recommendation) error 110 115 } 111 116 112 117 var DBRevision = 2 ··· 175 180 Label{}, 176 181 BroadcastOrigin{}, 177 182 MetadataConfiguration{}, 183 + Recommendation{}, 178 184 } { 179 185 err = db.AutoMigrate(model) 180 186 if err != nil {
+76
pkg/model/recommendations.go
··· 1 + package model 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "fmt" 7 + "time" 8 + 9 + "gorm.io/gorm" 10 + ) 11 + 12 + type Recommendation struct { 13 + UserDID string `gorm:"column:user_did;primaryKey"` 14 + Streamers json.RawMessage `gorm:"column:streamers;type:json;not null"` 15 + CreatedAt time.Time `gorm:"column:created_at"` 16 + UpdatedAt time.Time `gorm:"column:updated_at"` 17 + } 18 + 19 + func (r *Recommendation) TableName() string { 20 + return "recommendations" 21 + } 22 + 23 + func (r *Recommendation) GetStreamersArray() ([]string, error) { 24 + var streamers []string 25 + if err := json.Unmarshal(r.Streamers, &streamers); err != nil { 26 + return nil, fmt.Errorf("failed to unmarshal streamers: %w", err) 27 + } 28 + return streamers, nil 29 + } 30 + 31 + // UpsertRecommendation creates or updates recommendations for a user 32 + func (m *DBModel) UpsertRecommendation(rec *Recommendation) error { 33 + if rec.UserDID == "" { 34 + return fmt.Errorf("user DID cannot be empty") 35 + } 36 + 37 + // Validate JSON contains array of max 8 DIDs 38 + var streamers []string 39 + if err := json.Unmarshal(rec.Streamers, &streamers); err != nil { 40 + return fmt.Errorf("invalid streamers JSON: %w", err) 41 + } 42 + if len(streamers) > 8 { 43 + return fmt.Errorf("maximum 8 recommendations allowed, got %d", len(streamers)) 44 + } 45 + 46 + now := time.Now() 47 + if rec.CreatedAt.IsZero() { 48 + rec.CreatedAt = now 49 + } 50 + rec.UpdatedAt = now 51 + 52 + // Use GORM's upsert (On Conflict Do Update) 53 + result := m.DB.Save(rec) 54 + if result.Error != nil { 55 + return fmt.Errorf("database upsert failed: %w", result.Error) 56 + } 57 + 58 + return nil 59 + } 60 + 61 + // GetRecommendation retrieves a valid recommendation from a user 62 + func (m *DBModel) GetRecommendation(userDID string) (*Recommendation, error) { 63 + if userDID == "" { 64 + return nil, fmt.Errorf("user DID cannot be empty") 65 + } 66 + 67 + var rec Recommendation 68 + err := m.DB.Where("user_did = ?", userDID).First(&rec).Error 69 + if err != nil { 70 + if errors.Is(err, gorm.ErrRecordNotFound) { 71 + return nil, err 72 + } 73 + return nil, fmt.Errorf("database query failed: %w", err) 74 + } 75 + return &rec, nil 76 + }
+11
pkg/model/repo.go
··· 77 77 func (m *DBModel) UpdateRepo(repo *Repo) error { 78 78 return m.DB.Save(repo).Error 79 79 } 80 + 81 + func (m *DBModel) SearchReposByHandle(query string, limit int) ([]Repo, error) { 82 + var repos []Repo 83 + // Search for repos where handle starts with the query (case-insensitive) 84 + // Use LIKE with LOWER for sqlite/postgres compatibility 85 + res := m.DB.Where("LOWER(handle) LIKE LOWER(?)", query+"%").Limit(limit).Find(&repos) 86 + if res.Error != nil { 87 + return nil, res.Error 88 + } 89 + return repos, nil 90 + }
+21
pkg/model/segment.go
··· 279 279 return &seg, nil 280 280 } 281 281 282 + func (m *DBModel) FilterLiveRepoDIDs(repoDIDs []string) ([]string, error) { 283 + if len(repoDIDs) == 0 { 284 + return []string{}, nil 285 + } 286 + 287 + thirtySecondsAgo := time.Now().Add(-30 * time.Second) 288 + 289 + var liveDIDs []string 290 + 291 + err := m.DB.Table("segments"). 292 + Select("DISTINCT repo_did"). 293 + Where("repo_did IN ? AND start_time > ?", repoDIDs, thirtySecondsAgo.UTC()). 294 + Pluck("repo_did", &liveDIDs).Error 295 + 296 + if err != nil { 297 + return nil, err 298 + } 299 + 300 + return liveDIDs, nil 301 + } 302 + 282 303 func (m *DBModel) LatestSegmentsForUser(user string, limit int, before *time.Time, after *time.Time) ([]Segment, error) { 283 304 var segs []Segment 284 305 if before == nil {
+99
pkg/spxrpc/place_stream_live.go
··· 153 153 log.Debug(c.Request().Context(), "received message", "message", string(msg)) 154 154 } 155 155 } 156 + 157 + func (s *Server) handlePlaceStreamLiveGetRecommendations(ctx context.Context, userDID string) (*placestreamtypes.LiveGetRecommendations_Output, error) { 158 + if userDID == "" { 159 + return nil, echo.NewHTTPError(http.StatusBadRequest, "userDID is required") 160 + } 161 + 162 + // Try to get streamer's recommendation list 163 + rec, err := s.model.GetRecommendation(userDID) 164 + // If we have a recommendation list, filter for live streamers 165 + if err == nil { 166 + streamers, err := rec.GetStreamersArray() 167 + if err != nil { 168 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to parse recommendations") 169 + } 170 + 171 + // Filter for only live streamers 172 + liveStreamers, err := s.model.FilterLiveRepoDIDs(streamers) 173 + if err != nil { 174 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to filter live streamers") 175 + } 176 + 177 + if len(liveStreamers) > 0 { 178 + var recommendations []*placestreamtypes.LiveGetRecommendations_Output_Recommendations_Elem 179 + for _, did := range liveStreamers { 180 + recommendations = append(recommendations, &placestreamtypes.LiveGetRecommendations_Output_Recommendations_Elem{ 181 + LiveGetRecommendations_LivestreamRecommendation: &placestreamtypes.LiveGetRecommendations_LivestreamRecommendation{ 182 + Did: did, 183 + Source: "streamer", 184 + }, 185 + }) 186 + } 187 + return &placestreamtypes.LiveGetRecommendations_Output{ 188 + Recommendations: recommendations, 189 + UserDID: &userDID, 190 + }, nil 191 + } 192 + } else { 193 + // not a big issue but we should log anyways 194 + log.Log(ctx, "no recommendations found for user", "userDID", userDID) 195 + } 196 + 197 + // get user's follows and check which are live 198 + follows, err := s.model.GetUserFollowing(ctx, userDID) 199 + if err == nil && len(follows) > 0 { 200 + followDIDs := make([]string, len(follows)) 201 + for i, follow := range follows { 202 + followDIDs[i] = follow.SubjectDID 203 + } 204 + 205 + liveFollows, err := s.model.FilterLiveRepoDIDs(followDIDs) 206 + if err != nil { 207 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to filter live follows") 208 + } 209 + 210 + if len(liveFollows) > 0 { 211 + var recommendations []*placestreamtypes.LiveGetRecommendations_Output_Recommendations_Elem 212 + for _, did := range liveFollows { 213 + recommendations = append(recommendations, &placestreamtypes.LiveGetRecommendations_Output_Recommendations_Elem{ 214 + LiveGetRecommendations_LivestreamRecommendation: &placestreamtypes.LiveGetRecommendations_LivestreamRecommendation{ 215 + Did: did, 216 + Source: "follows", 217 + }, 218 + }) 219 + } 220 + return &placestreamtypes.LiveGetRecommendations_Output{ 221 + Recommendations: recommendations, 222 + UserDID: &userDID, 223 + }, nil 224 + } 225 + } 226 + 227 + // Final fallback: use host's default recommendations 228 + defaultStreamers := s.cli.DefaultRecommendedStreamers 229 + if len(defaultStreamers) > 0 { 230 + liveDefaults, err := s.model.FilterLiveRepoDIDs(defaultStreamers) 231 + if err != nil { 232 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to filter default streamers") 233 + } 234 + var recommendations []*placestreamtypes.LiveGetRecommendations_Output_Recommendations_Elem 235 + for _, did := range liveDefaults { 236 + recommendations = append(recommendations, &placestreamtypes.LiveGetRecommendations_Output_Recommendations_Elem{ 237 + LiveGetRecommendations_LivestreamRecommendation: &placestreamtypes.LiveGetRecommendations_LivestreamRecommendation{ 238 + Did: did, 239 + Source: "host", 240 + }, 241 + }) 242 + } 243 + return &placestreamtypes.LiveGetRecommendations_Output{ 244 + Recommendations: recommendations, 245 + UserDID: &userDID, 246 + }, nil 247 + } 248 + 249 + // No recommendations available 250 + return &placestreamtypes.LiveGetRecommendations_Output{ 251 + Recommendations: []*placestreamtypes.LiveGetRecommendations_Output_Recommendations_Elem{}, 252 + UserDID: &userDID, 253 + }, nil 254 + }
+45
pkg/spxrpc/place_stream_live_searchActorsTypeahead.go
··· 1 + package spxrpc 2 + 3 + import ( 4 + "context" 5 + "net/http" 6 + 7 + "github.com/labstack/echo/v4" 8 + placestreamtypes "stream.place/streamplace/pkg/streamplace" 9 + ) 10 + 11 + func (s *Server) handlePlaceStreamLiveSearchActorsTypeahead(ctx context.Context, limit int, q string) (*placestreamtypes.LiveSearchActorsTypeahead_Output, error) { 12 + if q == "" { 13 + return &placestreamtypes.LiveSearchActorsTypeahead_Output{ 14 + Actors: []*placestreamtypes.LiveSearchActorsTypeahead_Actor{}, 15 + }, nil 16 + } 17 + 18 + // Default limit to 10 if not specified 19 + searchLimit := 10 20 + if limit > 0 { 21 + searchLimit = limit 22 + if searchLimit > 100 { 23 + searchLimit = 100 24 + } 25 + } 26 + 27 + // Search repos by handle 28 + repos, err := s.model.SearchReposByHandle(q, searchLimit) 29 + if err != nil { 30 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to search actors "+err.Error()) 31 + } 32 + 33 + // Convert to output format 34 + actors := make([]*placestreamtypes.LiveSearchActorsTypeahead_Actor, len(repos)) 35 + for i, repo := range repos { 36 + actors[i] = &placestreamtypes.LiveSearchActorsTypeahead_Actor{ 37 + Did: repo.DID, 38 + Handle: repo.Handle, 39 + } 40 + } 41 + 42 + return &placestreamtypes.LiveSearchActorsTypeahead_Output{ 43 + Actors: actors, 44 + }, nil 45 + }
+41
pkg/spxrpc/stubs.go
··· 266 266 e.GET("/xrpc/place.stream.graph.getFollowingUser", s.HandlePlaceStreamGraphGetFollowingUser) 267 267 e.GET("/xrpc/place.stream.live.getLiveUsers", s.HandlePlaceStreamLiveGetLiveUsers) 268 268 e.GET("/xrpc/place.stream.live.getProfileCard", s.HandlePlaceStreamLiveGetProfileCard) 269 + e.GET("/xrpc/place.stream.live.getRecommendations", s.HandlePlaceStreamLiveGetRecommendations) 269 270 e.GET("/xrpc/place.stream.live.getSegments", s.HandlePlaceStreamLiveGetSegments) 271 + e.GET("/xrpc/place.stream.live.searchActorsTypeahead", s.HandlePlaceStreamLiveSearchActorsTypeahead) 270 272 e.POST("/xrpc/place.stream.server.createWebhook", s.HandlePlaceStreamServerCreateWebhook) 271 273 e.POST("/xrpc/place.stream.server.deleteWebhook", s.HandlePlaceStreamServerDeleteWebhook) 272 274 e.GET("/xrpc/place.stream.server.getServerTime", s.HandlePlaceStreamServerGetServerTime) ··· 343 345 return c.Stream(200, "application/octet-stream", out) 344 346 } 345 347 348 + func (s *Server) HandlePlaceStreamLiveGetRecommendations(c echo.Context) error { 349 + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamLiveGetRecommendations") 350 + defer span.End() 351 + userDID := c.QueryParam("userDID") 352 + var out *placestream.LiveGetRecommendations_Output 353 + var handleErr error 354 + // func (s *Server) handlePlaceStreamLiveGetRecommendations(ctx context.Context,userDID string) (*placestream.LiveGetRecommendations_Output, error) 355 + out, handleErr = s.handlePlaceStreamLiveGetRecommendations(ctx, userDID) 356 + if handleErr != nil { 357 + return handleErr 358 + } 359 + return c.JSON(200, out) 360 + } 361 + 346 362 func (s *Server) HandlePlaceStreamLiveGetSegments(c echo.Context) error { 347 363 ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamLiveGetSegments") 348 364 defer span.End() ··· 363 379 var handleErr error 364 380 // func (s *Server) handlePlaceStreamLiveGetSegments(ctx context.Context,before string,limit int,userDID string) (*placestream.LiveGetSegments_Output, error) 365 381 out, handleErr = s.handlePlaceStreamLiveGetSegments(ctx, before, limit, userDID) 382 + if handleErr != nil { 383 + return handleErr 384 + } 385 + return c.JSON(200, out) 386 + } 387 + 388 + func (s *Server) HandlePlaceStreamLiveSearchActorsTypeahead(c echo.Context) error { 389 + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamLiveSearchActorsTypeahead") 390 + defer span.End() 391 + 392 + var limit int 393 + if p := c.QueryParam("limit"); p != "" { 394 + var err error 395 + limit, err = strconv.Atoi(p) 396 + if err != nil { 397 + return err 398 + } 399 + } else { 400 + limit = 10 401 + } 402 + q := c.QueryParam("q") 403 + var out *placestream.LiveSearchActorsTypeahead_Output 404 + var handleErr error 405 + // func (s *Server) handlePlaceStreamLiveSearchActorsTypeahead(ctx context.Context,limit int,q string) (*placestream.LiveSearchActorsTypeahead_Output, error) 406 + out, handleErr = s.handlePlaceStreamLiveSearchActorsTypeahead(ctx, limit, q) 366 407 if handleErr != nil { 367 408 return handleErr 368 409 }
+203
pkg/streamplace/cbor_gen.go
··· 4975 4975 4976 4976 return nil 4977 4977 } 4978 + func (t *LiveRecommendations) MarshalCBOR(w io.Writer) error { 4979 + if t == nil { 4980 + _, err := w.Write(cbg.CborNull) 4981 + return err 4982 + } 4983 + 4984 + cw := cbg.NewCborWriter(w) 4985 + 4986 + if _, err := cw.Write([]byte{163}); err != nil { 4987 + return err 4988 + } 4989 + 4990 + // t.LexiconTypeID (string) (string) 4991 + if len("$type") > 1000000 { 4992 + return xerrors.Errorf("Value in field \"$type\" was too long") 4993 + } 4994 + 4995 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 4996 + return err 4997 + } 4998 + if _, err := cw.WriteString(string("$type")); err != nil { 4999 + return err 5000 + } 5001 + 5002 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("place.stream.live.recommendations"))); err != nil { 5003 + return err 5004 + } 5005 + if _, err := cw.WriteString(string("place.stream.live.recommendations")); err != nil { 5006 + return err 5007 + } 5008 + 5009 + // t.CreatedAt (string) (string) 5010 + if len("createdAt") > 1000000 { 5011 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 5012 + } 5013 + 5014 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 5015 + return err 5016 + } 5017 + if _, err := cw.WriteString(string("createdAt")); err != nil { 5018 + return err 5019 + } 5020 + 5021 + if len(t.CreatedAt) > 1000000 { 5022 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 5023 + } 5024 + 5025 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 5026 + return err 5027 + } 5028 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 5029 + return err 5030 + } 5031 + 5032 + // t.Streamers ([]string) (slice) 5033 + if len("streamers") > 1000000 { 5034 + return xerrors.Errorf("Value in field \"streamers\" was too long") 5035 + } 5036 + 5037 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("streamers"))); err != nil { 5038 + return err 5039 + } 5040 + if _, err := cw.WriteString(string("streamers")); err != nil { 5041 + return err 5042 + } 5043 + 5044 + if len(t.Streamers) > 8192 { 5045 + return xerrors.Errorf("Slice value in field t.Streamers was too long") 5046 + } 5047 + 5048 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Streamers))); err != nil { 5049 + return err 5050 + } 5051 + for _, v := range t.Streamers { 5052 + if len(v) > 1000000 { 5053 + return xerrors.Errorf("Value in field v was too long") 5054 + } 5055 + 5056 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 5057 + return err 5058 + } 5059 + if _, err := cw.WriteString(string(v)); err != nil { 5060 + return err 5061 + } 5062 + 5063 + } 5064 + return nil 5065 + } 5066 + 5067 + func (t *LiveRecommendations) UnmarshalCBOR(r io.Reader) (err error) { 5068 + *t = LiveRecommendations{} 5069 + 5070 + cr := cbg.NewCborReader(r) 5071 + 5072 + maj, extra, err := cr.ReadHeader() 5073 + if err != nil { 5074 + return err 5075 + } 5076 + defer func() { 5077 + if err == io.EOF { 5078 + err = io.ErrUnexpectedEOF 5079 + } 5080 + }() 5081 + 5082 + if maj != cbg.MajMap { 5083 + return fmt.Errorf("cbor input should be of type map") 5084 + } 5085 + 5086 + if extra > cbg.MaxLength { 5087 + return fmt.Errorf("LiveRecommendations: map struct too large (%d)", extra) 5088 + } 5089 + 5090 + n := extra 5091 + 5092 + nameBuf := make([]byte, 9) 5093 + for i := uint64(0); i < n; i++ { 5094 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 5095 + if err != nil { 5096 + return err 5097 + } 5098 + 5099 + if !ok { 5100 + // Field doesn't exist on this type, so ignore it 5101 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 5102 + return err 5103 + } 5104 + continue 5105 + } 5106 + 5107 + switch string(nameBuf[:nameLen]) { 5108 + // t.LexiconTypeID (string) (string) 5109 + case "$type": 5110 + 5111 + { 5112 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 5113 + if err != nil { 5114 + return err 5115 + } 5116 + 5117 + t.LexiconTypeID = string(sval) 5118 + } 5119 + // t.CreatedAt (string) (string) 5120 + case "createdAt": 5121 + 5122 + { 5123 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 5124 + if err != nil { 5125 + return err 5126 + } 5127 + 5128 + t.CreatedAt = string(sval) 5129 + } 5130 + // t.Streamers ([]string) (slice) 5131 + case "streamers": 5132 + 5133 + maj, extra, err = cr.ReadHeader() 5134 + if err != nil { 5135 + return err 5136 + } 5137 + 5138 + if extra > 8192 { 5139 + return fmt.Errorf("t.Streamers: array too large (%d)", extra) 5140 + } 5141 + 5142 + if maj != cbg.MajArray { 5143 + return fmt.Errorf("expected cbor array") 5144 + } 5145 + 5146 + if extra > 0 { 5147 + t.Streamers = make([]string, extra) 5148 + } 5149 + 5150 + for i := 0; i < int(extra); i++ { 5151 + { 5152 + var maj byte 5153 + var extra uint64 5154 + var err error 5155 + _ = maj 5156 + _ = extra 5157 + _ = err 5158 + 5159 + { 5160 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 5161 + if err != nil { 5162 + return err 5163 + } 5164 + 5165 + t.Streamers[i] = string(sval) 5166 + } 5167 + 5168 + } 5169 + } 5170 + 5171 + default: 5172 + // Field doesn't exist on this type, so ignore it 5173 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 5174 + return err 5175 + } 5176 + } 5177 + } 5178 + 5179 + return nil 5180 + }
+72
pkg/streamplace/livegetRecommendations.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.live.getRecommendations 4 + 5 + package streamplace 6 + 7 + import ( 8 + "context" 9 + "encoding/json" 10 + "fmt" 11 + 12 + lexutil "github.com/bluesky-social/indigo/lex/util" 13 + ) 14 + 15 + // LiveGetRecommendations_LivestreamRecommendation is a "livestreamRecommendation" in the place.stream.live.getRecommendations schema. 16 + type LiveGetRecommendations_LivestreamRecommendation struct { 17 + LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.live.getRecommendations#livestreamRecommendation"` 18 + // did: The DID of the recommended streamer 19 + Did string `json:"did" cborgen:"did"` 20 + // source: Source of the recommendation 21 + Source string `json:"source" cborgen:"source"` 22 + } 23 + 24 + // LiveGetRecommendations_Output is the output of a place.stream.live.getRecommendations call. 25 + type LiveGetRecommendations_Output struct { 26 + // recommendations: Ordered list of recommendations 27 + Recommendations []*LiveGetRecommendations_Output_Recommendations_Elem `json:"recommendations" cborgen:"recommendations"` 28 + // userDID: The user DID this recommendation is for 29 + UserDID *string `json:"userDID,omitempty" cborgen:"userDID,omitempty"` 30 + } 31 + 32 + type LiveGetRecommendations_Output_Recommendations_Elem struct { 33 + LiveGetRecommendations_LivestreamRecommendation *LiveGetRecommendations_LivestreamRecommendation 34 + } 35 + 36 + func (t *LiveGetRecommendations_Output_Recommendations_Elem) MarshalJSON() ([]byte, error) { 37 + if t.LiveGetRecommendations_LivestreamRecommendation != nil { 38 + t.LiveGetRecommendations_LivestreamRecommendation.LexiconTypeID = "place.stream.live.getRecommendations#livestreamRecommendation" 39 + return json.Marshal(t.LiveGetRecommendations_LivestreamRecommendation) 40 + } 41 + return nil, fmt.Errorf("can not marshal empty union as JSON") 42 + } 43 + 44 + func (t *LiveGetRecommendations_Output_Recommendations_Elem) UnmarshalJSON(b []byte) error { 45 + typ, err := lexutil.TypeExtract(b) 46 + if err != nil { 47 + return err 48 + } 49 + 50 + switch typ { 51 + case "place.stream.live.getRecommendations#livestreamRecommendation": 52 + t.LiveGetRecommendations_LivestreamRecommendation = new(LiveGetRecommendations_LivestreamRecommendation) 53 + return json.Unmarshal(b, t.LiveGetRecommendations_LivestreamRecommendation) 54 + default: 55 + return nil 56 + } 57 + } 58 + 59 + // LiveGetRecommendations calls the XRPC method "place.stream.live.getRecommendations". 60 + // 61 + // userDID: The DID of the user whose recommendations to fetch 62 + func LiveGetRecommendations(ctx context.Context, c lexutil.LexClient, userDID string) (*LiveGetRecommendations_Output, error) { 63 + var out LiveGetRecommendations_Output 64 + 65 + params := map[string]interface{}{} 66 + params["userDID"] = userDID 67 + if err := c.LexDo(ctx, lexutil.Query, "", "place.stream.live.getRecommendations", params, nil, &out); err != nil { 68 + return nil, err 69 + } 70 + 71 + return &out, nil 72 + }
+21
pkg/streamplace/liverecommendations.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.live.recommendations 4 + 5 + package streamplace 6 + 7 + import ( 8 + lexutil "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + func init() { 12 + lexutil.RegisterType("place.stream.live.recommendations", &LiveRecommendations{}) 13 + } 14 + 15 + type LiveRecommendations struct { 16 + LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.live.recommendations"` 17 + // createdAt: Client-declared timestamp when this list was created. 18 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 19 + // streamers: Ordered list of recommended streamer DIDs 20 + Streamers []string `json:"streamers" cborgen:"streamers"` 21 + }
+44
pkg/streamplace/livesearchActorsTypeahead.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.live.searchActorsTypeahead 4 + 5 + package streamplace 6 + 7 + import ( 8 + "context" 9 + 10 + lexutil "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + // LiveSearchActorsTypeahead_Actor is a "actor" in the place.stream.live.searchActorsTypeahead schema. 14 + type LiveSearchActorsTypeahead_Actor struct { 15 + // did: The actor's DID 16 + Did string `json:"did" cborgen:"did"` 17 + // handle: The actor's handle 18 + Handle string `json:"handle" cborgen:"handle"` 19 + } 20 + 21 + // LiveSearchActorsTypeahead_Output is the output of a place.stream.live.searchActorsTypeahead call. 22 + type LiveSearchActorsTypeahead_Output struct { 23 + Actors []*LiveSearchActorsTypeahead_Actor `json:"actors" cborgen:"actors"` 24 + } 25 + 26 + // LiveSearchActorsTypeahead calls the XRPC method "place.stream.live.searchActorsTypeahead". 27 + // 28 + // q: Search query prefix; not a full query string. 29 + func LiveSearchActorsTypeahead(ctx context.Context, c lexutil.LexClient, limit int64, q string) (*LiveSearchActorsTypeahead_Output, error) { 30 + var out LiveSearchActorsTypeahead_Output 31 + 32 + params := map[string]interface{}{} 33 + if limit != 0 { 34 + params["limit"] = limit 35 + } 36 + if q != "" { 37 + params["q"] = q 38 + } 39 + if err := c.LexDo(ctx, lexutil.Query, "", "place.stream.live.searchActorsTypeahead", params, nil, &out); err != nil { 40 + return nil, err 41 + } 42 + 43 + return &out, nil 44 + }
+50
pnpm-lock.yaml
··· 240 240 react-native: 241 241 specifier: 0.79.3 242 242 version: 0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 243 + react-native-draggable-flatlist: 244 + specifier: ^4.0.3 245 + version: 4.0.3(@babel/core@7.26.0)(react-native-gesture-handler@2.26.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native-reanimated@3.18.0(@babel/core@7.26.0)(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)) 243 246 react-native-edge-to-edge: 244 247 specifier: ^1.6.2 245 248 version: 1.6.2(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) ··· 264 267 react-native-screens: 265 268 specifier: ~4.11.1 266 269 version: 4.11.1(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 270 + react-native-sortables: 271 + specifier: ^1.9.4 272 + version: 1.9.4(react-native-gesture-handler@2.26.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native-reanimated@3.18.0(@babel/core@7.26.0)(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 267 273 react-native-svg: 268 274 specifier: 15.12.0 269 275 version: 15.12.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) ··· 10841 10847 react-is@18.3.1: 10842 10848 resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} 10843 10849 10850 + react-native-draggable-flatlist@4.0.3: 10851 + resolution: {integrity: sha512-2F4x5BFieWdGq9SetD2nSAR7s7oQCSgNllYgERRXXtNfSOuAGAVbDb/3H3lP0y5f7rEyNwabKorZAD/SyyNbDw==} 10852 + peerDependencies: 10853 + react-native: '>=0.64.0' 10854 + react-native-gesture-handler: '>=2.0.0' 10855 + react-native-reanimated: '>=2.8.0' 10856 + 10844 10857 react-native-edge-to-edge@1.6.0: 10845 10858 resolution: {integrity: sha512-2WCNdE3Qd6Fwg9+4BpbATUxCLcouF6YRY7K+J36KJ4l3y+tWN6XCqAC4DuoGblAAbb2sLkhEDp4FOlbOIot2Og==} 10846 10859 peerDependencies: ··· 10861 10874 peerDependencies: 10862 10875 react: '*' 10863 10876 react-native: '*' 10877 + 10878 + react-native-haptic-feedback@2.3.3: 10879 + resolution: {integrity: sha512-svS4D5PxfNv8o68m9ahWfwje5NqukM3qLS48+WTdhbDkNUkOhP9rDfDSRHzlhk4zq+ISjyw95EhLeh8NkKX5vQ==} 10880 + peerDependencies: 10881 + react-native: '>=0.60.0' 10864 10882 10865 10883 react-native-is-edge-to-edge@1.1.7: 10866 10884 resolution: {integrity: sha512-EH6i7E8epJGIcu7KpfXYXiV2JFIYITtq+rVS8uEb+92naMRBdxhTuS8Wn2Q7j9sqyO0B+Xbaaf9VdipIAmGW4w==} ··· 10914 10932 peerDependencies: 10915 10933 react: '*' 10916 10934 react-native: '*' 10935 + 10936 + react-native-sortables@1.9.4: 10937 + resolution: {integrity: sha512-a6hxT+gl14HA5Sm8UiLXJqF8KMEQVa+mUJd75OnzoVsmrxUDtjAatlMdV0kI9qTQDT/ZSFLPRmdUhOR762IA4g==} 10938 + peerDependencies: 10939 + react: '*' 10940 + react-native: '*' 10941 + react-native-gesture-handler: '>=2.0.0' 10942 + react-native-reanimated: '>=3.0.0' 10917 10943 10918 10944 react-native-svg@15.12.0: 10919 10945 resolution: {integrity: sha512-iE25PxIJ6V0C6krReLquVw6R0QTsRTmEQc4K2Co3P6zsimU/jltcDBKYDy1h/5j9S/fqmMeXnpM+9LEWKJKI6A==} ··· 27076 27102 27077 27103 react-is@18.3.1: {} 27078 27104 27105 + react-native-draggable-flatlist@4.0.3(@babel/core@7.26.0)(react-native-gesture-handler@2.26.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native-reanimated@3.18.0(@babel/core@7.26.0)(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)): 27106 + dependencies: 27107 + '@babel/preset-typescript': 7.24.7(@babel/core@7.26.0) 27108 + react-native: 0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 27109 + react-native-gesture-handler: 2.26.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 27110 + react-native-reanimated: 3.18.0(@babel/core@7.26.0)(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 27111 + transitivePeerDependencies: 27112 + - '@babel/core' 27113 + - supports-color 27114 + 27079 27115 react-native-edge-to-edge@1.6.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0): 27080 27116 dependencies: 27081 27117 react: 19.0.0 ··· 27115 27151 invariant: 2.2.4 27116 27152 react: 19.0.0 27117 27153 react-native: 0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 27154 + 27155 + react-native-haptic-feedback@2.3.3(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)): 27156 + dependencies: 27157 + react-native: 0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 27158 + optional: true 27118 27159 27119 27160 react-native-is-edge-to-edge@1.1.7(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0): 27120 27161 dependencies: ··· 27233 27274 react-native: 0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 27234 27275 react-native-is-edge-to-edge: 1.1.7(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 27235 27276 warn-once: 0.1.1 27277 + 27278 + react-native-sortables@1.9.4(react-native-gesture-handler@2.26.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native-reanimated@3.18.0(@babel/core@7.26.0)(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0): 27279 + dependencies: 27280 + react: 19.0.0 27281 + react-native: 0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 27282 + react-native-gesture-handler: 2.26.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 27283 + react-native-reanimated: 3.18.0(@babel/core@7.26.0)(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 27284 + optionalDependencies: 27285 + react-native-haptic-feedback: 2.3.3(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)) 27236 27286 27237 27287 react-native-svg@15.12.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0): 27238 27288 dependencies: