Live video on the AT Protocol

recommendations basic APIs and UI

+1680 -16
+1 -2
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, 24 25 TopControlBar, 25 26 } from "./desktop-ui/index"; 26 27 import { useResponsiveLayout } from "./useResponsiveLayout"; 27 - import { useSafeAreaInsets } from "react-native-safe-area-context"; 28 28 29 29 const { h, layout, position, w, px, py, r, p } = zero; 30 30 ··· 220 220 ingest={ingest} 221 221 isChatOpen={isChatOpen || false} 222 222 onToggleChat={toggleChat} 223 - safeAreaInsets={safeAreaInsets} 224 223 embedded={embedded} 225 224 /> 226 225 </Animated.View>
+9 -10
js/app/components/mobile/player.tsx
··· 13 13 usePlayerDimensions, 14 14 usePlayerStore, 15 15 useSegment, 16 - useSegmentDimensions, 17 16 View, 18 17 } from "@streamplace/components"; 19 18 import { gap, h, pt, w } from "@streamplace/components/src/lib/theme/atoms"; ··· 27 26 useSharedValue, 28 27 withTiming, 29 28 } from "react-native-reanimated"; 30 - import { useAppSelector } from "store/hooks"; 29 + import { useUserProfile } from "store/hooks"; 31 30 import { BottomMetadata } from "./bottom-metadata"; 32 31 import { DesktopChatPanel } from "./chat"; 33 32 import { DesktopUi } from "./desktop-ui"; ··· 35 34 import { MobileUi } from "./ui"; 36 35 import { useResponsiveLayout } from "./useResponsiveLayout"; 37 36 38 - import { 39 - setSidebarHidden, 40 - setSidebarUnhidden, 41 - } from "features/base/sidebarSlice"; 42 - import { useDispatch } from "react-redux"; 37 + import { useSafeAreaInsets } from "react-native-safe-area-context"; 38 + import { useStore } from "store"; 43 39 import { UserOffline } from "./user-offline"; 44 40 45 41 const SEGMENT_TIMEOUT = 500; // half a sec ··· 225 221 showChatSidePanelOnLandscape: props.showChat, 226 222 }); 227 223 224 + const safeAreaInsets = useSafeAreaInsets(); 225 + const setSidebarHidden = useStore((state) => state.setSidebarHidden); 226 + const setSidebarUnhidden = useStore((state) => state.setSidebarUnhidden); 227 + 228 228 // auto-collapse chat once when going offline 229 229 const hasCollapsedChat = useRef(false); 230 230 useEffect(() => { ··· 251 251 heightMultiplier.value = withTiming(1, { duration: 500 }); 252 252 } 253 253 }, [props.showUnavailable]); 254 - 255 - // for hiding sidebar 256 - const dispatch = useDispatch(); 257 254 258 255 // content info 259 256 const { width, height } = usePlayerDimensions(); ··· 290 287 291 288 const showFullDesktopMode = aspectRatio > 1 && screenWidth > 1200; 292 289 const isLandscape = aspectRatio > 1; 290 + 291 + const isPlayerRatioGreater = aspectRatio >= 16 / 9; 293 292 294 293 // animated style for offline height transition 295 294 const animatedHeightStyle = useAnimatedStyle(() => {
+567
js/app/components/settings/recommendations-manager.tsx
··· 1 + import { 2 + Button, 3 + Dialog, 4 + Input, 5 + MenuContainer, 6 + MenuGroup, 7 + MenuInfo, 8 + MenuSeparator, 9 + Text, 10 + zero, 11 + } from "@streamplace/components"; 12 + import { usePDSAgent } from "@streamplace/components/src/streamplace-store/xrpc"; 13 + import Loading from "components/loading/loading"; 14 + import { Plus, RefreshCw, Search, X } from "lucide-react-native"; 15 + import { useCallback, useEffect, useState } from "react"; 16 + import { useTranslation } from "react-i18next"; 17 + import { Alert, Pressable, ScrollView, View } from "react-native"; 18 + 19 + const { text, mt, mb, px, py, w, layout, gap, r, p } = zero; 20 + 21 + interface ActorSearchResult { 22 + did: string; 23 + handle: string; 24 + } 25 + 26 + export default function RecommendationsManager() { 27 + const agent = usePDSAgent(); 28 + const { theme } = zero.useTheme(); 29 + const [streamers, setStreamers] = useState<string[]>([]); 30 + const [loading, setLoading] = useState(true); 31 + const [saving, setSaving] = useState(false); 32 + const [deleteDialog, setDeleteDialog] = useState<{ 33 + isVisible: boolean; 34 + index: number | null; 35 + }>({ isVisible: false, index: null }); 36 + const [errors, setErrors] = useState<Record<number, string>>({}); 37 + 38 + // Search state 39 + const [searchQuery, setSearchQuery] = useState(""); 40 + const [searchResults, setSearchResults] = useState<ActorSearchResult[]>([]); 41 + const [searching, setSearching] = useState(false); 42 + const [searchDebounceTimeout, setSearchDebounceTimeout] = 43 + useState<NodeJS.Timeout | null>(null); 44 + 45 + const { t } = useTranslation("settings"); 46 + 47 + const loadRecommendations = async () => { 48 + if (!agent) return; 49 + 50 + try { 51 + setLoading(true); 52 + const userDID = agent.did; 53 + if (!userDID) { 54 + setStreamers([]); 55 + return; 56 + } 57 + 58 + // Get the record directly from the PDS for editing 59 + const response = await agent.com.atproto.repo.getRecord({ 60 + repo: userDID, 61 + collection: "place.stream.live.recommendations", 62 + rkey: "self", 63 + }); 64 + 65 + const record = response.data.value as any; 66 + setStreamers(record.streamers || []); 67 + } catch (error: any) { 68 + console.error("Failed to load recommendations:", error); 69 + if (error.status !== 404) { 70 + Alert.alert( 71 + "Error", 72 + "Failed to load recommendations. Please try again.", 73 + ); 74 + } 75 + setStreamers([]); 76 + } finally { 77 + setLoading(false); 78 + } 79 + }; 80 + 81 + const saveRecommendations = async (newStreamers: string[]) => { 82 + if (!agent || saving) return; 83 + 84 + try { 85 + if (!agent.did) { 86 + throw new Error("Agent DID is not available"); 87 + } 88 + setSaving(true); 89 + 90 + await agent.place.stream.live.recommendations.create( 91 + { 92 + repo: agent.did, 93 + }, 94 + { 95 + createdAt: new Date().toISOString(), 96 + streamers: newStreamers, 97 + }, 98 + ); 99 + 100 + setStreamers(newStreamers); 101 + } catch (error: any) { 102 + console.error("Failed to save recommendations:", error); 103 + Alert.alert( 104 + "Error", 105 + error.message || "Failed to save recommendations. Please try again.", 106 + ); 107 + // Reload to get back to consistent state 108 + await loadRecommendations(); 109 + } finally { 110 + setSaving(false); 111 + } 112 + }; 113 + 114 + const searchActors = useCallback( 115 + async (query: string) => { 116 + if (!agent || !query.trim()) { 117 + setSearchResults([]); 118 + return; 119 + } 120 + 121 + try { 122 + setSearching(true); 123 + const response = await agent.place.stream.live.searchActorsTypeahead({ 124 + q: query, 125 + limit: 10, 126 + }); 127 + 128 + setSearchResults( 129 + response.data.actors.map((actor: any) => ({ 130 + did: actor.did, 131 + handle: actor.handle, 132 + })), 133 + ); 134 + } catch (error: any) { 135 + console.error("Failed to search actors:", error); 136 + setSearchResults([]); 137 + } finally { 138 + setSearching(false); 139 + } 140 + }, 141 + [agent], 142 + ); 143 + 144 + const handleSearchChange = (query: string) => { 145 + setSearchQuery(query); 146 + 147 + // Clear previous timeout 148 + if (searchDebounceTimeout) { 149 + clearTimeout(searchDebounceTimeout); 150 + } 151 + 152 + // Set new timeout for debounced search 153 + if (query.trim()) { 154 + const timeout = setTimeout(() => { 155 + searchActors(query); 156 + }, 300); 157 + setSearchDebounceTimeout(timeout); 158 + } else { 159 + setSearchResults([]); 160 + } 161 + }; 162 + 163 + const handleSelectActor = async (actor: ActorSearchResult) => { 164 + if (streamers.length >= 8) { 165 + Alert.alert( 166 + "Maximum Reached", 167 + "You can only add up to 8 recommendations.", 168 + ); 169 + return; 170 + } 171 + 172 + if (streamers.includes(actor.did)) { 173 + Alert.alert( 174 + "Already Added", 175 + "This streamer is already in your recommendations.", 176 + ); 177 + return; 178 + } 179 + 180 + const newStreamers = [...streamers, actor.did]; 181 + await saveRecommendations(newStreamers); 182 + 183 + // Clear search 184 + setSearchQuery(""); 185 + setSearchResults([]); 186 + }; 187 + 188 + const validateDID = (did: string, index: number): boolean => { 189 + const trimmed = did.trim(); 190 + if (!trimmed) { 191 + setErrors((prev) => ({ ...prev, [index]: "DID is required" })); 192 + return false; 193 + } 194 + if (!trimmed.startsWith("did:")) { 195 + setErrors((prev) => ({ 196 + ...prev, 197 + [index]: "DID must start with 'did:'", 198 + })); 199 + return false; 200 + } 201 + setErrors((prev) => { 202 + const newErrors = { ...prev }; 203 + delete newErrors[index]; 204 + return newErrors; 205 + }); 206 + return true; 207 + }; 208 + 209 + const handleStreamerChange = async (index: number, value: string) => { 210 + const newStreamers = [...streamers]; 211 + newStreamers[index] = value; 212 + setStreamers(newStreamers); 213 + }; 214 + 215 + const handleStreamerBlur = async (index: number) => { 216 + const value = streamers[index].trim(); 217 + if (!value) { 218 + // Empty field, just remove it 219 + const newStreamers = streamers.filter((_, i) => i !== index); 220 + await saveRecommendations(newStreamers); 221 + return; 222 + } 223 + 224 + if (validateDID(value, index)) { 225 + await saveRecommendations(streamers); 226 + } 227 + }; 228 + 229 + const handleAddRecommendation = () => { 230 + if (streamers.length >= 8) { 231 + Alert.alert( 232 + "Maximum Reached", 233 + "You can only add up to 8 recommendations.", 234 + ); 235 + return; 236 + } 237 + setStreamers([...streamers, ""]); 238 + }; 239 + 240 + const handleDelete = (index: number) => { 241 + setDeleteDialog({ isVisible: true, index }); 242 + }; 243 + 244 + const confirmDelete = async () => { 245 + if (deleteDialog.index === null) return; 246 + 247 + const newStreamers = streamers.filter((_, i) => i !== deleteDialog.index); 248 + await saveRecommendations(newStreamers); 249 + setDeleteDialog({ isVisible: false, index: null }); 250 + }; 251 + 252 + useEffect(() => { 253 + if (!agent) return; 254 + loadRecommendations(); 255 + }, [agent]); 256 + 257 + // Cleanup timeout on unmount 258 + useEffect(() => { 259 + return () => { 260 + if (searchDebounceTimeout) { 261 + clearTimeout(searchDebounceTimeout); 262 + } 263 + }; 264 + }, [searchDebounceTimeout]); 265 + 266 + if (!agent) { 267 + return <Loading />; 268 + } 269 + 270 + return ( 271 + <> 272 + <ScrollView> 273 + <View style={[zero.layout.flex.align.center, zero.px[2], zero.py[2]]}> 274 + <View style={{ maxWidth: 800, width: "100%" }}> 275 + <MenuContainer> 276 + <View style={[mb[2]]}> 277 + <View 278 + style={[ 279 + layout.flex.row, 280 + layout.flex.justify.between, 281 + layout.flex.alignCenter, 282 + ]} 283 + > 284 + <Text size="xl">{t("recommendations-to-others")}</Text> 285 + <Button 286 + onPress={loadRecommendations} 287 + disabled={loading || saving} 288 + leftIcon={<RefreshCw size={16} color={theme.colors.text} />} 289 + size="pill" 290 + width="min" 291 + variant="secondary" 292 + > 293 + <Text size="sm">{t("refresh")}</Text> 294 + </Button> 295 + </View> 296 + </View> 297 + 298 + <MenuInfo description={t("recommendations-description")} /> 299 + </MenuContainer> 300 + 301 + {/* Search Bar */} 302 + {streamers.length < 8 && ( 303 + <MenuContainer> 304 + <MenuGroup> 305 + <View style={[px[3], py[2]]}> 306 + <View 307 + style={[ 308 + layout.flex.row, 309 + layout.flex.alignCenter, 310 + gap.all[2], 311 + ]} 312 + > 313 + <Search size={20} color={theme.colors.textMuted} /> 314 + <Input 315 + value={searchQuery} 316 + onChangeText={handleSearchChange} 317 + placeholder="Search for streamers..." 318 + /> 319 + </View> 320 + </View> 321 + 322 + {searching && ( 323 + <> 324 + <MenuSeparator /> 325 + <View style={[py[2], layout.flex.center]}> 326 + <Text 327 + size="sm" 328 + style={{ color: theme.colors.textMuted }} 329 + > 330 + Searching... 331 + </Text> 332 + </View> 333 + </> 334 + )} 335 + 336 + {!searching && searchResults.length > 0 && ( 337 + <> 338 + <MenuSeparator /> 339 + {searchResults.map((actor, index) => { 340 + const alreadyAdded = streamers.includes(actor.did); 341 + return ( 342 + <View key={actor.did}> 343 + {index > 0 && <MenuSeparator />} 344 + <Pressable 345 + onPress={() => 346 + !alreadyAdded && handleSelectActor(actor) 347 + } 348 + disabled={alreadyAdded} 349 + > 350 + {({ pressed }) => ( 351 + <View 352 + style={[ 353 + px[3], 354 + py[2], 355 + layout.flex.row, 356 + layout.flex.alignCenter, 357 + gap.all[2], 358 + r.md, 359 + { 360 + backgroundColor: 361 + pressed && !alreadyAdded 362 + ? "#ffffff08" 363 + : "transparent", 364 + opacity: alreadyAdded ? 0.5 : 1, 365 + }, 366 + ]} 367 + > 368 + <View style={{ flex: 1 }}> 369 + <Text>@{actor.handle}</Text> 370 + </View> 371 + {alreadyAdded && ( 372 + <Text 373 + size="xs" 374 + style={{ color: theme.colors.textMuted }} 375 + > 376 + Added 377 + </Text> 378 + )} 379 + </View> 380 + )} 381 + </Pressable> 382 + </View> 383 + ); 384 + })} 385 + </> 386 + )} 387 + 388 + {!searching && 389 + searchQuery.trim() && 390 + searchResults.length === 0 && ( 391 + <> 392 + <MenuSeparator /> 393 + <View style={[py[2], layout.flex.center]}> 394 + <Text 395 + size="sm" 396 + style={{ color: theme.colors.textMuted }} 397 + > 398 + No results found 399 + </Text> 400 + </View> 401 + </> 402 + )} 403 + </MenuGroup> 404 + 405 + {searchQuery.trim() === "" && ( 406 + <MenuInfo description="Search for streamers by handle or name, or enter DIDs manually below" /> 407 + )} 408 + </MenuContainer> 409 + )} 410 + 411 + {loading ? ( 412 + <Loading /> 413 + ) : ( 414 + <MenuContainer> 415 + <MenuGroup> 416 + {streamers.length === 0 ? ( 417 + <View style={[py[4], layout.flex.center]}> 418 + <Text size="sm" style={{ color: theme.colors.textMuted }}> 419 + {t("no-recommendations-yet")} 420 + </Text> 421 + </View> 422 + ) : ( 423 + streamers.map((streamer, index) => ( 424 + <View key={index}> 425 + {index > 0 && <MenuSeparator />} 426 + <View 427 + style={[ 428 + px[3], 429 + py[2], 430 + layout.flex.row, 431 + layout.flex.alignCenter, 432 + gap.all[2], 433 + ]} 434 + > 435 + <Text 436 + size="sm" 437 + style={{ 438 + color: theme.colors.textMuted, 439 + minWidth: 24, 440 + }} 441 + > 442 + #{index + 1} 443 + </Text> 444 + <View style={{ flex: 1 }}> 445 + <Input 446 + value={streamer} 447 + onChangeText={(value) => 448 + handleStreamerChange(index, value) 449 + } 450 + onBlur={() => handleStreamerBlur(index)} 451 + placeholder="did:plc:..." 452 + /> 453 + {errors[index] && ( 454 + <Text 455 + size="xs" 456 + style={{ 457 + color: theme.colors.destructive, 458 + marginTop: 4, 459 + }} 460 + > 461 + {errors[index]} 462 + </Text> 463 + )} 464 + </View> 465 + <Pressable 466 + onPress={() => handleDelete(index)} 467 + style={({ pressed }) => [ 468 + { 469 + padding: 8, 470 + borderRadius: 6, 471 + backgroundColor: pressed 472 + ? "#ffffff08" 473 + : "transparent", 474 + }, 475 + ]} 476 + > 477 + <X size={18} color={theme.colors.destructive} /> 478 + </Pressable> 479 + </View> 480 + </View> 481 + )) 482 + )} 483 + 484 + {streamers.length > 0 && streamers.length < 8 && ( 485 + <MenuSeparator /> 486 + )} 487 + 488 + {streamers.length < 8 && ( 489 + <Pressable onPress={handleAddRecommendation}> 490 + {({ pressed }) => ( 491 + <View 492 + style={[ 493 + px[3], 494 + py[2], 495 + layout.flex.row, 496 + layout.flex.alignCenter, 497 + gap.all[2], 498 + r.md, 499 + { 500 + backgroundColor: pressed 501 + ? "#ffffff08" 502 + : "transparent", 503 + }, 504 + ]} 505 + > 506 + <Plus size={20} color={theme.colors.text} /> 507 + <Text size="lg">Add DID manually</Text> 508 + </View> 509 + )} 510 + </Pressable> 511 + )} 512 + </MenuGroup> 513 + 514 + {saving && ( 515 + <View style={[mt[2], layout.flex.center]}> 516 + <Text size="sm" style={{ color: theme.colors.textMuted }}> 517 + {t("saving")} 518 + </Text> 519 + </View> 520 + )} 521 + </MenuContainer> 522 + )} 523 + </View> 524 + </View> 525 + </ScrollView> 526 + 527 + <Dialog 528 + open={deleteDialog.isVisible} 529 + onOpenChange={(open) => 530 + !open && setDeleteDialog({ isVisible: false, index: null }) 531 + } 532 + title={t("delete")} 533 + dismissible={false} 534 + > 535 + <View style={[w.percent[100], mb[8], mt[2]]}> 536 + <Text style={[{ fontSize: 24 }]}>{t("confirm-delete")}</Text> 537 + <Text 538 + style={[text.gray[400], mt[4], { fontSize: 18, fontWeight: "700" }]} 539 + > 540 + {t("action-cannot-be-undone")} 541 + </Text> 542 + </View> 543 + 544 + <View style={[layout.flex.row, layout.flex.justify.end, gap.all[3]]}> 545 + <Button 546 + variant="secondary" 547 + width="full" 548 + onPress={() => setDeleteDialog({ isVisible: false, index: null })} 549 + disabled={saving} 550 + > 551 + <Text>{t("cancel")}</Text> 552 + </Button> 553 + <Button 554 + variant="destructive" 555 + width="full" 556 + onPress={confirmDelete} 557 + disabled={saving} 558 + > 559 + <Text style={[text.white, { fontSize: 14, fontWeight: "500" }]}> 560 + {saving ? t("deleting") : t("delete")} 561 + </Text> 562 + </Button> 563 + </View> 564 + </Dialog> 565 + </> 566 + ); 567 + }
+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
+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", ··· 750 753 name="WebhooksSettings" 751 754 component={WebhookManager} 752 755 options={{ headerTitle: "Webhooks", title: "Webhooks" }} 756 + /> 757 + <Stack.Screen 758 + name="RecommendationsSettings" 759 + component={RecommendationsManager} 760 + options={{ headerTitle: "Recommendations", title: "Recommendations" }} 753 761 /> 754 762 <Stack.Screen 755 763 name="PrivacyCategory"
+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
+84
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 + | `streamers` | Array of `string` | ✅ | Ordered list of recommended streamer DIDs | | 34 + | `userDID` | `string` | ❌ | The user who created this recommendation list | Format: `did` | 35 + 36 + --- 37 + 38 + ## Lexicon Source 39 + 40 + ```json 41 + { 42 + "lexicon": 1, 43 + "id": "place.stream.live.getRecommendations", 44 + "defs": { 45 + "main": { 46 + "type": "query", 47 + "description": "Get the list of streamers recommended by a user", 48 + "parameters": { 49 + "type": "params", 50 + "required": ["userDID"], 51 + "properties": { 52 + "userDID": { 53 + "type": "string", 54 + "format": "did", 55 + "description": "The DID of the user whose recommendations to fetch" 56 + } 57 + } 58 + }, 59 + "output": { 60 + "encoding": "application/json", 61 + "schema": { 62 + "type": "object", 63 + "required": ["streamers"], 64 + "properties": { 65 + "streamers": { 66 + "type": "array", 67 + "description": "Ordered list of recommended streamer DIDs", 68 + "items": { 69 + "type": "string", 70 + "format": "did" 71 + } 72 + }, 73 + "userDID": { 74 + "type": "string", 75 + "format": "did", 76 + "description": "The user who created this recommendation list" 77 + } 78 + } 79 + } 80 + } 81 + } 82 + } 83 + } 84 + ```
+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:** `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": "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 + ```
+115
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 + "streamers": { 636 + "type": "array", 637 + "description": "Ordered list of recommended streamer DIDs", 638 + "items": { 639 + "type": "string", 640 + "format": "did" 641 + } 642 + }, 643 + "userDID": { 644 + "type": "string", 645 + "description": "The user who created this recommendation list", 646 + "format": "did" 647 + } 648 + }, 649 + "required": ["streamers"] 650 + } 651 + } 652 + } 653 + } 654 + }, 655 + "parameters": [ 656 + { 657 + "name": "userDID", 658 + "in": "query", 659 + "required": true, 660 + "description": "The DID of the user whose recommendations to fetch", 661 + "schema": { 662 + "type": "string", 663 + "description": "The DID of the user whose recommendations to fetch", 664 + "format": "did" 665 + } 666 + } 667 + ] 668 + } 669 + }, 622 670 "/xrpc/place.stream.live.getSegments": { 623 671 "get": { 624 672 "summary": "Get a list of livestream segments for a user", ··· 674 722 "schema": { 675 723 "type": "string", 676 724 "format": "date-time" 725 + } 726 + } 727 + ] 728 + } 729 + }, 730 + "/xrpc/place.stream.live.searchActorsTypeahead": { 731 + "get": { 732 + "summary": "Find actor suggestions for a prefix search term. Expected use is for auto-completion during text field entry.", 733 + "operationId": "place.stream.live.searchActorsTypeahead", 734 + "tags": ["place.stream.live"], 735 + "responses": { 736 + "200": { 737 + "description": "Success", 738 + "content": { 739 + "application/json": { 740 + "schema": { 741 + "type": "object", 742 + "properties": { 743 + "actors": { 744 + "type": "array", 745 + "items": { 746 + "$ref": "#/components/schemas/place.stream.live.searchActorsTypeahead_actor" 747 + } 748 + } 749 + }, 750 + "required": ["actors"] 751 + } 752 + } 753 + } 754 + } 755 + }, 756 + "parameters": [ 757 + { 758 + "name": "q", 759 + "in": "query", 760 + "required": false, 761 + "description": "Search query prefix; not a full query string.", 762 + "schema": { 763 + "type": "string", 764 + "description": "Search query prefix; not a full query string." 765 + } 766 + }, 767 + { 768 + "name": "limit", 769 + "in": "query", 770 + "required": false, 771 + "schema": { 772 + "type": "integer", 773 + "default": 10, 774 + "minimum": 1, 775 + "maximum": 100 677 776 } 678 777 } 679 778 ] ··· 2154 2253 "record": {} 2155 2254 }, 2156 2255 "required": ["cid", "record"] 2256 + }, 2257 + "place.stream.live.searchActorsTypeahead_actor": { 2258 + "type": "object", 2259 + "properties": { 2260 + "did": { 2261 + "type": "string", 2262 + "description": "The actor's DID", 2263 + "format": "did" 2264 + }, 2265 + "handle": { 2266 + "type": "string", 2267 + "description": "The actor's handle", 2268 + "format": "handle" 2269 + } 2270 + }, 2271 + "required": ["did", "handle"] 2157 2272 }, 2158 2273 "place.stream.live.subscribeSegments_segment": { 2159 2274 "type": "string",
+43
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": ["streamers"], 24 + "properties": { 25 + "streamers": { 26 + "type": "array", 27 + "description": "Ordered list of recommended streamer DIDs", 28 + "items": { 29 + "type": "string", 30 + "format": "did" 31 + } 32 + }, 33 + "userDID": { 34 + "type": "string", 35 + "format": "did", 36 + "description": "The user who created this recommendation list" 37 + } 38 + } 39 + } 40 + } 41 + } 42 + } 43 + }
+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": "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 + "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 := &statedb.Recommendation{ 449 + UserDID: userDID, 450 + Streamers: json.RawMessage(streamersJSON), 451 + CreatedAt: createdAt, 452 + } 453 + 454 + err = atsync.StatefulDB.UpsertRecommendation(recommendation) 455 + if err != nil { 456 + return fmt.Errorf("failed to upsert recommendation: %w", err) 424 457 } 425 458 426 459 default:
+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 }
+1
pkg/model/model.go
··· 48 48 GetRepoByHandleOrDID(arg string) (*Repo, error) 49 49 GetRepoBySigningKey(signingKey string) (*Repo, error) 50 50 GetAllRepos() ([]Repo, error) 51 + SearchReposByHandle(query string, limit int) ([]Repo, error) 51 52 UpdateRepo(repo *Repo) error 52 53 53 54 UpdateSigningKey(key *SigningKey) error
+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 + }
+25
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 + rec, err := s.statefulDB.GetRecommendation(userDID) 163 + if err != nil { 164 + // If not found, return empty array 165 + return &placestreamtypes.LiveGetRecommendations_Output{ 166 + Streamers: []string{}, 167 + UserDID: &userDID, 168 + }, nil 169 + } 170 + 171 + streamers, err := rec.GetStreamersArray() 172 + if err != nil { 173 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to parse recommendations") 174 + } 175 + 176 + return &placestreamtypes.LiveGetRecommendations_Output{ 177 + Streamers: streamers, 178 + UserDID: &userDID, 179 + }, nil 180 + }
+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 *placestreamtypes.LiveGetRecommendations_Output 353 + var handleErr error 354 + // func (s *Server) handlePlaceStreamLiveGetRecommendations(ctx context.Context,userDID string) (*placestreamtypes.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 *placestreamtypes.LiveSearchActorsTypeahead_Output 404 + var handleErr error 405 + // func (s *Server) handlePlaceStreamLiveSearchActorsTypeahead(ctx context.Context,limit int,q string) (*placestreamtypes.LiveSearchActorsTypeahead_Output, error) 406 + out, handleErr = s.handlePlaceStreamLiveSearchActorsTypeahead(ctx, limit, q) 366 407 if handleErr != nil { 367 408 return handleErr 368 409 }
+93
pkg/statedb/recommendations.go
··· 1 + package statedb 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 + // UpsertRecommendation creates or updates recommendations for a user 24 + func (state *StatefulDB) UpsertRecommendation(rec *Recommendation) error { 25 + if rec.UserDID == "" { 26 + return fmt.Errorf("user DID cannot be empty") 27 + } 28 + 29 + // Validate JSON contains array of max 8 DIDs 30 + var streamers []string 31 + if err := json.Unmarshal(rec.Streamers, &streamers); err != nil { 32 + return fmt.Errorf("invalid streamers JSON: %w", err) 33 + } 34 + if len(streamers) > 8 { 35 + return fmt.Errorf("maximum 8 recommendations allowed, got %d", len(streamers)) 36 + } 37 + 38 + now := time.Now() 39 + if rec.CreatedAt.IsZero() { 40 + rec.CreatedAt = now 41 + } 42 + rec.UpdatedAt = now 43 + 44 + // Use GORM's upsert (On Conflict Do Update) 45 + result := state.DB.Save(rec) 46 + if result.Error != nil { 47 + return fmt.Errorf("database upsert failed: %w", result.Error) 48 + } 49 + 50 + return nil 51 + } 52 + 53 + // GetRecommendation retrieves recommendations for a user 54 + func (state *StatefulDB) GetRecommendation(userDID string) (*Recommendation, error) { 55 + if userDID == "" { 56 + return nil, fmt.Errorf("user DID cannot be empty") 57 + } 58 + 59 + var rec Recommendation 60 + err := state.DB.Where("user_did = ?", userDID).First(&rec).Error 61 + if err != nil { 62 + if errors.Is(err, gorm.ErrRecordNotFound) { 63 + return nil, err 64 + } 65 + return nil, fmt.Errorf("database query failed: %w", err) 66 + } 67 + return &rec, nil 68 + } 69 + 70 + // DeleteRecommendation removes recommendations for a user 71 + func (state *StatefulDB) DeleteRecommendation(userDID string) error { 72 + if userDID == "" { 73 + return fmt.Errorf("user DID cannot be empty") 74 + } 75 + 76 + result := state.DB.Where("user_did = ?", userDID).Delete(&Recommendation{}) 77 + if result.Error != nil { 78 + return fmt.Errorf("database delete failed: %w", result.Error) 79 + } 80 + if result.RowsAffected == 0 { 81 + return fmt.Errorf("recommendation not found") 82 + } 83 + return nil 84 + } 85 + 86 + // GetStreamersArray is a helper to unmarshal the streamers JSON into a slice 87 + func (r *Recommendation) GetStreamersArray() ([]string, error) { 88 + var streamers []string 89 + if err := json.Unmarshal(r.Streamers, &streamers); err != nil { 90 + return nil, fmt.Errorf("failed to unmarshal streamers: %w", err) 91 + } 92 + return streamers, nil 93 + }
+1
pkg/statedb/statedb.go
··· 49 49 AppTask{}, 50 50 Repo{}, 51 51 Webhook{}, 52 + Recommendation{}, 52 53 } 53 54 54 55 var NoPostgresDatabaseCode = "3D000"
+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 + }
+34
pkg/streamplace/livegetRecommendations.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package streamplace 4 + 5 + // schema: place.stream.live.getRecommendations 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + // LiveGetRecommendations_Output is the output of a place.stream.live.getRecommendations call. 14 + type LiveGetRecommendations_Output struct { 15 + // streamers: Ordered list of recommended streamer DIDs 16 + Streamers []string `json:"streamers" cborgen:"streamers"` 17 + // userDID: The user who created this recommendation list 18 + UserDID *string `json:"userDID,omitempty" cborgen:"userDID,omitempty"` 19 + } 20 + 21 + // LiveGetRecommendations calls the XRPC method "place.stream.live.getRecommendations". 22 + // 23 + // userDID: The DID of the user whose recommendations to fetch 24 + func LiveGetRecommendations(ctx context.Context, c util.LexClient, userDID string) (*LiveGetRecommendations_Output, error) { 25 + var out LiveGetRecommendations_Output 26 + 27 + params := map[string]interface{}{} 28 + params["userDID"] = userDID 29 + if err := c.LexDo(ctx, util.Query, "", "place.stream.live.getRecommendations", params, nil, &out); err != nil { 30 + return nil, err 31 + } 32 + 33 + return &out, nil 34 + }
+21
pkg/streamplace/liverecommendations.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package streamplace 4 + 5 + // schema: place.stream.live.recommendations 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + func init() { 12 + util.RegisterType("place.stream.live.recommendations", &LiveRecommendations{}) 13 + } // 14 + // RECORDTYPE: LiveRecommendations 15 + type LiveRecommendations struct { 16 + LexiconTypeID string `json:"$type,const=place.stream.live.recommendations" 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 + package streamplace 4 + 5 + // schema: place.stream.live.searchActorsTypeahead 6 + 7 + import ( 8 + "context" 9 + 10 + "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 util.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, util.Query, "", "place.stream.live.searchActorsTypeahead", params, nil, &out); err != nil { 40 + return nil, err 41 + } 42 + 43 + return &out, nil 44 + }