Live video on the AT Protocol

Compare changes

Choose any two refs to compare.

+2935 -399
+6
.prettierrc
··· 6 "options": { 7 "proseWrap": "preserve" 8 } 9 } 10 ], 11 "plugins": ["prettier-plugin-organize-imports"]
··· 6 "options": { 7 "proseWrap": "preserve" 8 } 9 + }, 10 + { 11 + "files": "*.md", 12 + "options": { 13 + "proseWrap": "preserve" 14 + } 15 } 16 ], 17 "plugins": ["prettier-plugin-organize-imports"]
+1 -1
go.mod
··· 61 github.com/slok/go-http-metrics v0.13.0 62 github.com/starttoaster/prometheus-exporter-scraper v0.0.1 63 github.com/streamplace/atproto-oauth-golang v0.0.0-20250619231223-a9c04fb888ac 64 - github.com/streamplace/oatproxy v0.0.0-20260112011721-d74b4913c93f 65 github.com/stretchr/testify v1.11.1 66 github.com/tdewolff/canvas v0.0.0-20250728095813-50d4cb1eee71 67 github.com/whyrusleeping/cbor-gen v0.3.1
··· 61 github.com/slok/go-http-metrics v0.13.0 62 github.com/starttoaster/prometheus-exporter-scraper v0.0.1 63 github.com/streamplace/atproto-oauth-golang v0.0.0-20250619231223-a9c04fb888ac 64 + github.com/streamplace/oatproxy v0.0.0-20260130124113-420429019d3b 65 github.com/stretchr/testify v1.11.1 66 github.com/tdewolff/canvas v0.0.0-20250728095813-50d4cb1eee71 67 github.com/whyrusleeping/cbor-gen v0.3.1
+2
go.sum
··· 1319 github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4/go.mod h1:bGUXY9Wd4mnd+XUrOYZr358J2f6z9QO/dLhL1SsiD+0= 1320 github.com/streamplace/oatproxy v0.0.0-20260112011721-d74b4913c93f h1:hhbQ8CtcAZVlLit/r7b9QDK7qEgOth4hgE13xV6ViBI= 1321 github.com/streamplace/oatproxy v0.0.0-20260112011721-d74b4913c93f/go.mod h1:pXi24hA7xBHj8eEywX6wGqJOR9FaEYlGwQ/72rN6okw= 1322 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 1323 github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 1324 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
··· 1319 github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4/go.mod h1:bGUXY9Wd4mnd+XUrOYZr358J2f6z9QO/dLhL1SsiD+0= 1320 github.com/streamplace/oatproxy v0.0.0-20260112011721-d74b4913c93f h1:hhbQ8CtcAZVlLit/r7b9QDK7qEgOth4hgE13xV6ViBI= 1321 github.com/streamplace/oatproxy v0.0.0-20260112011721-d74b4913c93f/go.mod h1:pXi24hA7xBHj8eEywX6wGqJOR9FaEYlGwQ/72rN6okw= 1322 + github.com/streamplace/oatproxy v0.0.0-20260130124113-420429019d3b h1:BB/R1egvkEqZhGeKL3tqAlTn0mkoOaaMY6r6s18XJYA= 1323 + github.com/streamplace/oatproxy v0.0.0-20260130124113-420429019d3b/go.mod h1:pXi24hA7xBHj8eEywX6wGqJOR9FaEYlGwQ/72rN6okw= 1324 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 1325 github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 1326 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
-2
js/app/components/live-dashboard/bento-grid.tsx
··· 155 streamTitle={ 156 profile?.displayName || profile?.handle || "Live Stream" 157 } 158 - viewers={viewers || 0} 159 uptime={getUptime()} 160 bitrate={getBitrate()} 161 timeBetweenSegments={segmentTiming.timeBetweenSegments || 0} ··· 235 streamTitle={ 236 profile?.displayName || profile?.handle || "Live Stream" 237 } 238 - viewers={viewers || 0} 239 uptime={getUptime()} 240 bitrate={getBitrate()} 241 timeBetweenSegments={segmentTiming.timeBetweenSegments || 0}
··· 155 streamTitle={ 156 profile?.displayName || profile?.handle || "Live Stream" 157 } 158 uptime={getUptime()} 159 bitrate={getBitrate()} 160 timeBetweenSegments={segmentTiming.timeBetweenSegments || 0} ··· 234 streamTitle={ 235 profile?.displayName || profile?.handle || "Live Stream" 236 } 237 uptime={getUptime()} 238 bitrate={getBitrate()} 239 timeBetweenSegments={segmentTiming.timeBetweenSegments || 0}
+5 -1
js/app/components/live-dashboard/stream-monitor.tsx
··· 104 <View style={[flex.values[1], layout.flex.center, bg.neutral[900]]}> 105 {isLive && userProfile ? ( 106 isStreamVisible ? ( 107 - <Player src={userProfile.did} name={userProfile.handle}> 108 <DesktopUi /> 109 <PlayerUI.ViewerLoadingOverlay /> 110 <OfflineCounter isMobile={true} />
··· 104 <View style={[flex.values[1], layout.flex.center, bg.neutral[900]]}> 105 {isLive && userProfile ? ( 106 isStreamVisible ? ( 107 + <Player 108 + src={userProfile.did} 109 + name={userProfile.handle} 110 + muted={true} 111 + > 112 <DesktopUi /> 113 <PlayerUI.ViewerLoadingOverlay /> 114 <OfflineCounter isMobile={true} />
+11 -5
js/app/components/login/login-form.tsx
··· 20 21 interface LoginFormProps { 22 onSuccess?: () => void; 23 } 24 25 - export default function LoginForm({ onSuccess }: LoginFormProps) { 26 const { theme } = useTheme(); 27 const loginAction = useStore((state) => state.login); 28 const openLoginLink = useStore((state) => state.openLoginLink); ··· 74 }; 75 76 const onSignup = () => { 77 - // TODO: remove requirement for oauth-protected-resource in oatproxy 78 - loginAction("https://bsky.social", openLoginLink); 79 }; 80 81 const isMobile = Platform.OS === "ios" || Platform.OS === "android"; ··· 284 ]} 285 > 286 <Button width="min" onPress={() => onSignup()} variant="ghost"> 287 - <Text style={[{ color: "white" }]}>Sign Up on Bluesky</Text> 288 </Button> 289 <Button 290 onPress={submit} ··· 293 width="min" 294 loading={loginState.loading} 295 > 296 - <Text style={[{ color: "white" }]}>Log in</Text> 297 </Button> 298 </View> 299 </>
··· 20 21 interface LoginFormProps { 22 onSuccess?: () => void; 23 + onCloseModal?: () => void; 24 + onOpenPdsModal?: () => void; 25 } 26 27 + export default function LoginForm({ 28 + onSuccess, 29 + onCloseModal, 30 + onOpenPdsModal, 31 + }: LoginFormProps) { 32 const { theme } = useTheme(); 33 const loginAction = useStore((state) => state.login); 34 const openLoginLink = useStore((state) => state.openLoginLink); ··· 80 }; 81 82 const onSignup = () => { 83 + onCloseModal?.(); 84 + onOpenPdsModal?.(); 85 }; 86 87 const isMobile = Platform.OS === "ios" || Platform.OS === "android"; ··· 290 ]} 291 > 292 <Button width="min" onPress={() => onSignup()} variant="ghost"> 293 + <Text style={[{ color: "white" }]}>Sign Up</Text> 294 </Button> 295 <Button 296 onPress={submit} ··· 299 width="min" 300 loading={loginState.loading} 301 > 302 + <Text style={[{ color: "white" }]}>Log In</Text> 303 </Button> 304 </View> 305 </>
+15 -2
js/app/components/login/login-modal.tsx
··· 6 interface LoginModalProps { 7 visible: boolean; 8 onClose: () => void; 9 } 10 11 - export default function LoginModal({ visible, onClose }: LoginModalProps) { 12 const { theme, zero: z } = useTheme(); 13 14 return ( 15 <Modal ··· 64 </TouchableOpacity> 65 </View> 66 67 - <LoginForm onSuccess={onClose} /> 68 </Pressable> 69 </View> 70 </Modal>
··· 6 interface LoginModalProps { 7 visible: boolean; 8 onClose: () => void; 9 + onOpenPdsModal: () => void; 10 } 11 12 + export default function LoginModal({ 13 + visible, 14 + onClose, 15 + onOpenPdsModal, 16 + }: LoginModalProps) { 17 const { theme, zero: z } = useTheme(); 18 + 19 + if (!visible) { 20 + return null; 21 + } 22 23 return ( 24 <Modal ··· 73 </TouchableOpacity> 74 </View> 75 76 + <LoginForm 77 + onSuccess={onClose} 78 + onCloseModal={onClose} 79 + onOpenPdsModal={onOpenPdsModal} 80 + /> 81 </Pressable> 82 </View> 83 </Modal>
+3 -1
js/app/components/login/login.tsx
··· 12 export default function Login() { 13 const { theme } = useTheme(); 14 const closeLoginModal = useStore((state) => state.closeLoginModal); 15 const userProfile = useUserProfile(); 16 const navigation = useNavigation(); 17 const isReady = useIsReady(); ··· 26 27 // check for stored return route on mount 28 useEffect(() => { 29 storage.getItem("returnRoute").then((stored) => { 30 if (stored) { 31 try { ··· 103 <Text style={[{ fontSize: 36, fontWeight: "200", color: "white" }]}> 104 Log in 105 </Text> 106 - <LoginForm /> 107 </View> 108 </View> 109 </ScrollView>
··· 12 export default function Login() { 13 const { theme } = useTheme(); 14 const closeLoginModal = useStore((state) => state.closeLoginModal); 15 + const openPdsModal = useStore((state) => state.openPdsModal); 16 const userProfile = useUserProfile(); 17 const navigation = useNavigation(); 18 const isReady = useIsReady(); ··· 27 28 // check for stored return route on mount 29 useEffect(() => { 30 + if (Platform.OS !== "web") return; 31 storage.getItem("returnRoute").then((stored) => { 32 if (stored) { 33 try { ··· 105 <Text style={[{ fontSize: 36, fontWeight: "200", color: "white" }]}> 106 Log in 107 </Text> 108 + <LoginForm onOpenPdsModal={openPdsModal} /> 109 </View> 110 </View> 111 </ScrollView>
+364
js/app/components/login/pds-host-selector-modal.tsx
···
··· 1 + import { 2 + Admonition, 3 + Button, 4 + Checkbox, 5 + Input, 6 + ResponsiveDialog, 7 + Trans as T, 8 + Text, 9 + useTheme, 10 + useTranslation, 11 + zero, 12 + } from "@streamplace/components"; 13 + import { Check, ExternalLink } from "lucide-react-native"; 14 + import React, { useState } from "react"; 15 + import { Linking, Pressable, View } from "react-native"; 16 + 17 + interface PdsHost { 18 + value: string; 19 + label: string; 20 + description: string; 21 + handlePolicyDocs?: string; 22 + terms: string; 23 + privacy: string; 24 + } 25 + 26 + const PDS_HOSTS = [ 27 + { 28 + value: "https://selfhosted.social", 29 + label: "selfhosted.social", 30 + description: "A popular community-run PDS", 31 + terms: "https://selfhosted.social/legal#terms", 32 + privacy: "https://selfhosted.social/legal", 33 + }, 34 + { 35 + // will redirect to https://bsky.social for sign in :thumb: 36 + value: "https://witchesbutter.us-west.host.bsky.network", 37 + label: "Bluesky", 38 + description: "The main Bluesky PDS instance", 39 + terms: "https://bsky.social/about/support/tos", 40 + privacy: "https://bsky.social/about/support/privacy-policy", 41 + }, 42 + { 43 + value: "https://blacksky.app", 44 + label: "Blacksky PDS", 45 + description: "A PDS service by Blacksky Algorithms", 46 + terms: "https://blackskyweb.xyz/about/support/tos", 47 + privacy: "https://blackskyweb.xyz/about/support/privacy-policy/", 48 + handlePolicyDocs: 49 + "https://docs.blacksky.community/migrating-to-blacksky-pds-complete-guide#who-can-use-blacksky-services", 50 + }, 51 + { 52 + value: "https://pds.tophhie.cloud", 53 + label: "Tophhie Cloud", 54 + description: "A PDS service by Tophhie", 55 + terms: "https://blog.tophhie.cloud/atproto-tos/", 56 + privacy: "https://blog.tophhie.cloud/atproto-privacy-policy/", 57 + }, 58 + ]; 59 + 60 + // Shuffle the hosts 61 + // items with handle policies should never be first ! 62 + const shuffleArray = <T,>(array: T[]): T[] => { 63 + const arr = [...array]; 64 + for (let i = arr.length - 1; i > 0; i--) { 65 + const j = Math.floor(Math.random() * (i + 1)); 66 + [arr[i], arr[j]] = [arr[j], arr[i]]; 67 + } 68 + return arr; 69 + }; 70 + 71 + const SHUFFLED_PDS_HOSTS = (() => { 72 + const withPolicies = PDS_HOSTS.filter((h) => h.handlePolicyDocs); 73 + const [first, ...withoutPolicies] = PDS_HOSTS.filter( 74 + (h) => !h.handlePolicyDocs, 75 + ); 76 + return [first, ...shuffleArray(withPolicies.concat(withoutPolicies))]; 77 + })(); 78 + 79 + interface PdsHostSelectorModalProps { 80 + open: boolean; 81 + onOpenChange: (open: boolean) => void; 82 + onSubmit: (pdsHost: string) => void; 83 + } 84 + 85 + export const PdsHostSelectorModal: React.FC<PdsHostSelectorModalProps> = ({ 86 + open, 87 + onOpenChange, 88 + onSubmit, 89 + }) => { 90 + const [selectedHost, setSelectedHost] = useState<string | null>( 91 + SHUFFLED_PDS_HOSTS[0].value, 92 + ); 93 + const [customHost, setCustomHost] = useState<string>(""); 94 + const [useCustom, setUseCustom] = useState(false); 95 + const [handlePolicyChecked, hasCheckedHandlePolicy] = useState(false); 96 + 97 + const { theme } = useTheme(); 98 + const { t } = useTranslation(); 99 + 100 + const selectedHostObj = 101 + SHUFFLED_PDS_HOSTS.find((host) => host.value === selectedHost) || 102 + SHUFFLED_PDS_HOSTS[0]; 103 + 104 + const handleCancel = () => { 105 + setSelectedHost(SHUFFLED_PDS_HOSTS[0].value); 106 + setCustomHost(""); 107 + setUseCustom(false); 108 + onOpenChange(false); 109 + }; 110 + 111 + const handleSubmit = () => { 112 + const hostToUse = useCustom ? customHost : selectedHost; 113 + if (!hostToUse) return; 114 + 115 + onSubmit(hostToUse); 116 + handleCancel(); 117 + }; 118 + 119 + const handleLearnMore = () => { 120 + Linking.openURL("https://atproto.com/guides/self-hosting"); 121 + }; 122 + const handleTOS = () => { 123 + Linking.openURL(selectedHostObj.terms); 124 + }; 125 + const handlePrivacy = () => { 126 + Linking.openURL(selectedHostObj.privacy); 127 + }; 128 + 129 + const handleSelectHost = (value: string) => { 130 + setSelectedHost(value); 131 + setUseCustom(false); 132 + }; 133 + 134 + const handleSelectCustom = () => { 135 + setUseCustom(true); 136 + }; 137 + 138 + return ( 139 + <ResponsiveDialog 140 + open={open} 141 + onOpenChange={onOpenChange} 142 + showCloseButton={false} 143 + variant="default" 144 + size="sm" 145 + dismissible={false} 146 + position="center" 147 + > 148 + <View style={[{ maxWidth: 500 }]}> 149 + <View style={[zero.my[4]]}> 150 + <Text size="2xl" style={[zero.mb[2]]}> 151 + {t("pds-selector-title")} 152 + </Text> 153 + <Text style={[{ color: theme.colors.textMuted }]}> 154 + {t("pds-selector-description")} 155 + </Text> 156 + </View> 157 + <View style={[zero.pb[2]]}> 158 + {SHUFFLED_PDS_HOSTS.map((host, index) => ( 159 + <Pressable 160 + key={host.value} 161 + onPress={() => handleSelectHost(host.value)} 162 + style={[ 163 + zero.py[2], 164 + zero.px[3], 165 + zero.r.lg, 166 + { 167 + borderWidth: 1, 168 + borderColor: 169 + !useCustom && selectedHost === host.value 170 + ? theme.colors.primary 171 + : theme.colors.border, 172 + backgroundColor: 173 + !useCustom && selectedHost === host.value 174 + ? "rgba(0, 122, 255, 0.05)" 175 + : "transparent", 176 + }, 177 + index > 0 && zero.mt[2], 178 + ]} 179 + > 180 + <View 181 + style={[ 182 + zero.layout.flex.row, 183 + zero.layout.flex.spaceBetween, 184 + zero.layout.flex.alignCenter, 185 + ]} 186 + > 187 + <View style={[zero.flex[1]]}> 188 + <Text>{host.label}</Text> 189 + <Text 190 + style={[ 191 + zero.mt[1], 192 + { fontSize: 14, color: theme.colors.textMuted }, 193 + ]} 194 + > 195 + {host.description} 196 + </Text> 197 + </View> 198 + {!useCustom && selectedHost === host.value && ( 199 + <Check size={20} color={theme.colors.primary} /> 200 + )} 201 + </View> 202 + </Pressable> 203 + ))} 204 + 205 + <Pressable 206 + onPress={handleSelectCustom} 207 + style={[ 208 + zero.py[2], 209 + zero.px[3], 210 + zero.r.lg, 211 + zero.mt[2], 212 + { 213 + borderWidth: 1, 214 + borderColor: useCustom 215 + ? theme.colors.primary 216 + : theme.colors.border, 217 + backgroundColor: useCustom 218 + ? "rgba(0, 122, 255, 0.05)" 219 + : "transparent", 220 + }, 221 + ]} 222 + > 223 + <View 224 + style={[ 225 + zero.layout.flex.row, 226 + zero.layout.flex.spaceBetween, 227 + zero.layout.flex.alignCenter, 228 + ]} 229 + > 230 + <View style={[zero.flex[1]]}> 231 + <Text>{t("pds-selector-custom-label")}</Text> 232 + <Text 233 + style={[ 234 + zero.mt[1], 235 + { fontSize: 14, color: theme.colors.textMuted }, 236 + ]} 237 + > 238 + {t("pds-selector-custom-description")} 239 + </Text> 240 + </View> 241 + {useCustom && <Check size={20} color={theme.colors.primary} />} 242 + </View> 243 + </Pressable> 244 + 245 + <View style={[zero.mt[4]]}> 246 + <Pressable 247 + onPress={handleLearnMore} 248 + style={[ 249 + zero.layout.flex.row, 250 + zero.gap.all[1], 251 + zero.layout.flex.alignCenter, 252 + ]} 253 + > 254 + <Text style={[{ color: theme.colors.ring, fontSize: 14 }]}> 255 + {t("pds-selector-learn-more")} 256 + </Text> 257 + <ExternalLink size={16} color={theme.colors.ring} /> 258 + </Pressable> 259 + </View> 260 + 261 + {useCustom && ( 262 + <View style={[zero.mt[3]]}> 263 + <Text style={[zero.mb[2], { color: theme.colors.textMuted }]}> 264 + {t("pds-selector-custom-url-label")} 265 + </Text> 266 + <Input 267 + value={customHost} 268 + onChangeText={setCustomHost} 269 + placeholder={t("pds-selector-custom-url-placeholder")} 270 + autoCapitalize="none" 271 + autoCorrect={false} 272 + keyboardType="url" 273 + /> 274 + </View> 275 + )} 276 + <Admonition variant="info" style={[zero.my[4]] as any}> 277 + <Text style={[zero.mb[2]]}>{t("pds-selector-info")}</Text> 278 + {!useCustom && ( 279 + <Text style={[zero.mb[2]]}> 280 + <T 281 + i18nKey="pds-selector-read-policies" 282 + values={{ label: selectedHostObj?.label }} 283 + components={{ 284 + tosLink: ( 285 + <Text 286 + onPress={handleTOS} 287 + style={[{ color: theme.colors.ring }]} 288 + /> 289 + ), 290 + privacyLink: ( 291 + <Text 292 + onPress={handlePrivacy} 293 + style={[{ color: theme.colors.ring }]} 294 + /> 295 + ), 296 + }} 297 + /> 298 + </Text> 299 + )} 300 + </Admonition> 301 + {!useCustom && selectedHostObj.handlePolicyDocs && ( 302 + <View 303 + style={[ 304 + zero.layout.flex.row, 305 + zero.layout.flex.align.center, 306 + zero.layout.flex.justify.start, 307 + zero.gap.all[2], 308 + zero.mb[4], 309 + zero.mt[2], 310 + ]} 311 + > 312 + <Checkbox 313 + checked={handlePolicyChecked} 314 + onCheckedChange={hasCheckedHandlePolicy} 315 + /> 316 + <Text style={[zero.flex[1]]}> 317 + <T 318 + i18nKey="pds-selector-handle-policy-checkbox" 319 + components={{ 320 + policyLink: ( 321 + <Text 322 + onPress={() => 323 + Linking.openURL(selectedHostObj.handlePolicyDocs!) 324 + } 325 + style={[{ color: theme.colors.ring }]} 326 + > 327 + {selectedHostObj.label} guidelines and handle policy 328 + </Text> 329 + ), 330 + }} 331 + /> 332 + </Text> 333 + </View> 334 + )} 335 + </View> 336 + <View 337 + style={[ 338 + zero.flex[1], 339 + zero.layout.flex.row, 340 + zero.layout.flex.justify.end, 341 + zero.gap.all[2], 342 + ]} 343 + > 344 + <Button width="min" variant="secondary" onPress={handleCancel}> 345 + <Text>{t("cancel")}</Text> 346 + </Button> 347 + <Button 348 + width="min" 349 + variant="primary" 350 + onPress={handleSubmit} 351 + disabled={ 352 + (useCustom && !customHost.trim()) || 353 + (!handlePolicyChecked && !!selectedHostObj.handlePolicyDocs) 354 + } 355 + > 356 + <Text>{t("continue")}</Text> 357 + </Button> 358 + </View> 359 + </View> 360 + </ResponsiveDialog> 361 + ); 362 + }; 363 + 364 + export default PdsHostSelectorModal;
+1
js/app/components/mobile/desktop-ui.tsx
··· 254 setTitle={setTitle} 255 ingestStarting={ingestStarting} 256 toggleGoLive={toggleGoLive} 257 /> 258 )} 259
··· 254 setTitle={setTitle} 255 ingestStarting={ingestStarting} 256 toggleGoLive={toggleGoLive} 257 + isLive={isActivelyLive} 258 /> 259 )} 260
+16 -3
js/app/components/mobile/ui.tsx
··· 72 ingestStarting, 73 setIngestStarting, 74 toggleGoLive, 75 } = useLivestreamInfo(); 76 const { width, height } = usePlayerDimensions(); 77 const { isPlayerRatioGreater } = useSegmentDimensions(); ··· 102 103 const isSelfAndNotLive = ingest === "new"; 104 const isLive = ingest !== null && ingest !== "new"; 105 106 const FADE_OUT_DELAY = 4000; 107 const fadeOpacity = useSharedValue(1); ··· 222 <View 223 style={[ 224 layout.position.absolute, 225 - position.top[28], 226 position.left[0], 227 position.right[0], 228 layout.flex.column, ··· 230 ]} 231 > 232 <PlayerUI.MetricsPanel 233 - showMetrics={isLive || isSelfAndNotLive} 234 /> 235 </View> 236 )} ··· 241 setTitle={setTitle} 242 ingestStarting={ingestStarting} 243 toggleGoLive={toggleGoLive} 244 /> 245 )} 246 ··· 468 <Pressable onPress={doSetIngestCamera}> 469 <SwitchCamera color={theme.colors.foreground} size={20} /> 470 </Pressable> 471 </> 472 )} 473 {Platform.OS === "web" ? ( ··· 515 )} 516 </Pressable> 517 )} 518 - <PlayerUI.ContextMenu /> 519 </View> 520 )} 521 {shouldShowChatSidePanel && setShowChat && (
··· 72 ingestStarting, 73 setIngestStarting, 74 toggleGoLive, 75 + toggleStopStream, 76 } = useLivestreamInfo(); 77 const { width, height } = usePlayerDimensions(); 78 const { isPlayerRatioGreater } = useSegmentDimensions(); ··· 103 104 const isSelfAndNotLive = ingest === "new"; 105 const isLive = ingest !== null && ingest !== "new"; 106 + 107 + useEffect(() => { 108 + if (isLive && ingestStarting) { 109 + setIngestStarting(false); 110 + } 111 + }, [isLive, ingestStarting, setIngestStarting]); 112 113 const FADE_OUT_DELAY = 4000; 114 const fadeOpacity = useSharedValue(1); ··· 229 <View 230 style={[ 231 layout.position.absolute, 232 + position.top[32], 233 position.left[0], 234 position.right[0], 235 layout.flex.column, ··· 237 ]} 238 > 239 <PlayerUI.MetricsPanel 240 + showMetrics={shouldShowFloatingMetrics} 241 /> 242 </View> 243 )} ··· 248 setTitle={setTitle} 249 ingestStarting={ingestStarting} 250 toggleGoLive={toggleGoLive} 251 + isLive={isLive} 252 /> 253 )} 254 ··· 476 <Pressable onPress={doSetIngestCamera}> 477 <SwitchCamera color={theme.colors.foreground} size={20} /> 478 </Pressable> 479 + {Platform.OS === "web" && <PlayerUI.StreamContextMenu />} 480 </> 481 )} 482 {Platform.OS === "web" ? ( ··· 524 )} 525 </Pressable> 526 )} 527 + {ingest === null ? ( 528 + <PlayerUI.ContextMenu /> 529 + ) : ( 530 + <PlayerUI.StreamContextMenu /> 531 + )} 532 </View> 533 )} 534 {shouldShowChatSidePanel && setShowChat && (
+6 -1
js/app/features/bluesky/blueskyProvider.tsx
··· 2 import { storage } from "@streamplace/components"; 3 import { useURL } from "expo-linking"; 4 import { useEffect, useState } from "react"; 5 import { useStore } from "store"; 6 import { useIsReady, useOAuthSession, useUserProfile } from "store/hooks"; 7 import { navigateToRoute } from "utils/navigation"; ··· 23 loadOAuthClient(); 24 25 // load return route from storage on mount 26 storage.getItem("returnRoute").then((stored) => { 27 if (stored) { 28 try { ··· 82 if ( 83 lastAuthStatus !== "loggedIn" && 84 authStatus === "loggedIn" && 85 - returnRoute 86 ) { 87 console.log( 88 "Login successful, navigating back to returnRoute:",
··· 2 import { storage } from "@streamplace/components"; 3 import { useURL } from "expo-linking"; 4 import { useEffect, useState } from "react"; 5 + import { Platform } from "react-native"; 6 import { useStore } from "store"; 7 import { useIsReady, useOAuthSession, useUserProfile } from "store/hooks"; 8 import { navigateToRoute } from "utils/navigation"; ··· 24 loadOAuthClient(); 25 26 // load return route from storage on mount 27 + if (Platform.OS !== "web") { 28 + return; 29 + } 30 storage.getItem("returnRoute").then((stored) => { 31 if (stored) { 32 try { ··· 86 if ( 87 lastAuthStatus !== "loggedIn" && 88 authStatus === "loggedIn" && 89 + returnRoute && 90 + Platform.OS === "web" 91 ) { 92 console.log( 93 "Login successful, navigating back to returnRoute:",
+8 -3
js/app/hooks/useBlueskyNotifications.tsx
··· 1 import { useToast } from "@streamplace/components"; 2 import { CircleX } from "lucide-react-native"; 3 import { useEffect } from "react"; 4 import { useStore } from "../store"; 5 6 function titleCase(str: string) { ··· 18 let toast = useToast(); 19 const notification = useStore((state) => state.notification); 20 const clearNotification = useStore((state) => state.clearNotification); 21 22 useEffect(() => { 23 if (notification) { ··· 41 { 42 duration: 100, 43 variant: notification.type, 44 - actionLabel: "Copy message", 45 iconLeft: CircleX, 46 onAction: () => { 47 navigator.clipboard.writeText( ··· 59 notification.message, 60 { 61 variant: notification.type, 62 - actionLabel: "Copy message", 63 onAction: () => { 64 navigator.clipboard.writeText(notification.message); 65 }, ··· 74 notification.message, 75 { 76 variant: notification.type, 77 - actionLabel: "Copy message", 78 onAction: () => { 79 navigator.clipboard.writeText(notification.message); 80 },
··· 1 import { useToast } from "@streamplace/components"; 2 import { CircleX } from "lucide-react-native"; 3 import { useEffect } from "react"; 4 + import { Platform } from "react-native"; 5 + import clearQueryParams from "utils/clear-query-params"; 6 import { useStore } from "../store"; 7 8 function titleCase(str: string) { ··· 20 let toast = useToast(); 21 const notification = useStore((state) => state.notification); 22 const clearNotification = useStore((state) => state.clearNotification); 23 + 24 + // we've already saved the notif to the store 25 + clearQueryParams(["error", "error_description"]); 26 27 useEffect(() => { 28 if (notification) { ··· 46 { 47 duration: 100, 48 variant: notification.type, 49 + actionLabel: Platform.OS === "web" ? "Copy message" : undefined, 50 iconLeft: CircleX, 51 onAction: () => { 52 navigator.clipboard.writeText( ··· 64 notification.message, 65 { 66 variant: notification.type, 67 + actionLabel: Platform.OS === "web" ? "Copy message" : undefined, 68 onAction: () => { 69 navigator.clipboard.writeText(notification.message); 70 }, ··· 79 notification.message, 80 { 81 variant: notification.type, 82 + actionLabel: Platform.OS === "web" ? "Copy message" : undefined, 83 onAction: () => { 84 navigator.clipboard.writeText(notification.message); 85 },
+1 -1
js/app/package.json
··· 1 { 2 "name": "@streamplace/app", 3 "main": "./src/entrypoint.tsx", 4 - "version": "0.9.8", 5 "runtimeVersion": "0.7.2", 6 "scripts": { 7 "start": "npx expo start -c --port 38081",
··· 1 { 2 "name": "@streamplace/app", 3 "main": "./src/entrypoint.tsx", 4 + "version": "0.9.9", 5 "runtimeVersion": "0.7.2", 6 "scripts": { 7 "start": "npx expo start -c --port 38081",
+21 -7
js/app/src/router.tsx
··· 81 import HomeScreen from "./screens/home"; 82 83 import { useUrl } from "@streamplace/components"; 84 import { BrandingAdmin } from "components/settings/branding-admin"; 85 import { LanguagesCategorySettings } from "components/settings/languages-category-settings"; 86 import MultistreamManager from "components/settings/multistream-manager"; ··· 297 const AvatarButton = () => { 298 const userProfile = useUserProfile(); 299 const openLoginModal = useStore((state) => state.openLoginModal); 300 const loginAction = useStore((state) => state.login); 301 const openLoginLink = useStore((state) => state.openLoginLink); 302 const { theme } = useTheme(); ··· 332 ); 333 } 334 335 - const handleSignup = () => { 336 - // TODO: remove requirement for oauth-protected-resource in oatproxy 337 - loginAction("https://bsky.social", openLoginLink); 338 - }; 339 - 340 if (isCompact) { 341 return ( 342 <Button ··· 369 <Text style={{ color: theme.colors.text }}>Log In</Text> 370 </Button> 371 <Button 372 - onPress={handleSignup} 373 variant="primary" 374 width="min" 375 style={[zero.r.full]} ··· 477 const pollMySegments = useStore((state) => state.pollMySegments); 478 const showLoginModal = useStore((state) => state.showLoginModal); 479 const closeLoginModal = useStore((state) => state.closeLoginModal); 480 const [livePopup, setLivePopup] = useState(false); 481 const siteTitle = useSiteTitle(); 482 const defaultStreamer = useDefaultStreamer(); 483 ··· 784 }} 785 /> 786 </Drawer.Navigator> 787 - <LoginModal visible={showLoginModal} onClose={closeLoginModal} /> 788 </> 789 ); 790 }
··· 81 import HomeScreen from "./screens/home"; 82 83 import { useUrl } from "@streamplace/components"; 84 + import PdsHostSelectorModal from "components/login/pds-host-selector-modal"; 85 import { BrandingAdmin } from "components/settings/branding-admin"; 86 import { LanguagesCategorySettings } from "components/settings/languages-category-settings"; 87 import MultistreamManager from "components/settings/multistream-manager"; ··· 298 const AvatarButton = () => { 299 const userProfile = useUserProfile(); 300 const openLoginModal = useStore((state) => state.openLoginModal); 301 + const openPDSModal = useStore((state) => state.openPdsModal); 302 const loginAction = useStore((state) => state.login); 303 const openLoginLink = useStore((state) => state.openLoginLink); 304 const { theme } = useTheme(); ··· 334 ); 335 } 336 337 if (isCompact) { 338 return ( 339 <Button ··· 366 <Text style={{ color: theme.colors.text }}>Log In</Text> 367 </Button> 368 <Button 369 + onPress={() => openPDSModal()} 370 variant="primary" 371 width="min" 372 style={[zero.r.full]} ··· 474 const pollMySegments = useStore((state) => state.pollMySegments); 475 const showLoginModal = useStore((state) => state.showLoginModal); 476 const closeLoginModal = useStore((state) => state.closeLoginModal); 477 + const showPdsModal = useStore((state) => state.showPdsModal); 478 + const openPdsModal = useStore((state) => state.openPdsModal); 479 + const closePdsModal = useStore((state) => state.closePdsModal); 480 const [livePopup, setLivePopup] = useState(false); 481 + const loginAction = useStore((state) => state.login); 482 + const openLoginLink = useStore((state) => state.openLoginLink); 483 const siteTitle = useSiteTitle(); 484 const defaultStreamer = useDefaultStreamer(); 485 ··· 786 }} 787 /> 788 </Drawer.Navigator> 789 + <LoginModal 790 + visible={showLoginModal} 791 + onClose={closeLoginModal} 792 + onOpenPdsModal={openPdsModal} 793 + /> 794 + <PdsHostSelectorModal 795 + open={showPdsModal} 796 + onOpenChange={closePdsModal} 797 + onSubmit={(pdsHost) => { 798 + closePdsModal(); 799 + loginAction(pdsHost, openLoginLink); 800 + }} 801 + /> 802 </> 803 ); 804 }
+14 -16
js/app/store/slices/blueskySlice.ts
··· 19 PlaceStreamServerSettings, 20 StreamplaceAgent, 21 } from "streamplace"; 22 import { privateKeyToAccount } from "viem/accounts"; 23 import { StateCreator } from "zustand"; 24 import createOAuthClient, { ··· 86 showLoginModal: boolean; 87 openLoginModal: (returnRoute?: { name: string; params?: any }) => void; 88 closeLoginModal: () => void; 89 golivePost: ( 90 text: string, 91 now: Date, ··· 114 createServerSettingsRecord: (debugRecording: boolean) => Promise<void>; 115 } 116 117 - const clearQueryParams = () => { 118 - if (Platform.OS !== "web") { 119 - return; 120 - } 121 - const u = new URL(document.location.href); 122 - const params = new URLSearchParams(u.search); 123 - if (u.search === "") { 124 - return; 125 - } 126 - params.delete("iss"); 127 - params.delete("state"); 128 - params.delete("code"); 129 - u.search = params.toString(); 130 - window.history.replaceState(null, "", u.toString()); 131 - }; 132 - 133 const uploadThumbnail = async ( 134 handle: string, 135 u: URL, ··· 210 serverSettings: null, 211 returnRoute: null, 212 showLoginModal: false, 213 notification: null, 214 215 clearNotification: () => { 216 set({ notification: null }); 217 }, 218 ··· 237 closeLoginModal: () => { 238 console.log("closeLoginModal"); 239 set({ showLoginModal: false }); 240 }, 241 242 loadOAuthClient: async () => {
··· 19 PlaceStreamServerSettings, 20 StreamplaceAgent, 21 } from "streamplace"; 22 + import clearQueryParams from "utils/clear-query-params"; 23 import { privateKeyToAccount } from "viem/accounts"; 24 import { StateCreator } from "zustand"; 25 import createOAuthClient, { ··· 87 showLoginModal: boolean; 88 openLoginModal: (returnRoute?: { name: string; params?: any }) => void; 89 closeLoginModal: () => void; 90 + showPdsModal: boolean; 91 + openPdsModal: () => void; 92 + closePdsModal: () => void; 93 golivePost: ( 94 text: string, 95 now: Date, ··· 118 createServerSettingsRecord: (debugRecording: boolean) => Promise<void>; 119 } 120 121 const uploadThumbnail = async ( 122 handle: string, 123 u: URL, ··· 198 serverSettings: null, 199 returnRoute: null, 200 showLoginModal: false, 201 + showPdsModal: false, 202 notification: null, 203 204 clearNotification: () => { 205 + clearQueryParams(); 206 set({ notification: null }); 207 }, 208 ··· 227 closeLoginModal: () => { 228 console.log("closeLoginModal"); 229 set({ showLoginModal: false }); 230 + }, 231 + 232 + openPdsModal: () => { 233 + set({ showPdsModal: true }); 234 + }, 235 + 236 + closePdsModal: () => { 237 + set({ showPdsModal: false }); 238 }, 239 240 loadOAuthClient: async () => {
+15
js/app/utils/clear-query-params.ts
···
··· 1 + import { Platform } from "react-native"; 2 + 3 + export default function clearQueryParams(par = ["iss", "state", "code"]) { 4 + if (Platform.OS !== "web") { 5 + return; 6 + } 7 + const u = new URL(document.location.href); 8 + const params = new URLSearchParams(u.search); 9 + if (u.search === "") { 10 + return; 11 + } 12 + par.forEach((p) => params.delete(p)); 13 + u.search = params.toString(); 14 + window.history.replaceState(null, "", u.toString()); 15 + }
+3 -6
js/atproto-oauth-client-react-native/README.md
··· 87 forwarded the port with `adb reverse`. For testing on iOS hardware, you'll 88 instead need to set up TLS. 89 90 - [react-native-quick-crypto]: 91 - https://github.com/margelo/react-native-quick-crypto 92 [expo-sqlite]: https://docs.expo.dev/versions/latest/sdk/sqlite/ 93 - [README]: 94 - https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-browser 95 - [example]: 96 - https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-browser-example
··· 87 forwarded the port with `adb reverse`. For testing on iOS hardware, you'll 88 instead need to set up TLS. 89 90 + [react-native-quick-crypto]: https://github.com/margelo/react-native-quick-crypto 91 [expo-sqlite]: https://docs.expo.dev/versions/latest/sdk/sqlite/ 92 + [README]: https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-browser 93 + [example]: https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-browser-example
+1 -1
js/atproto-oauth-client-react-native/package.json
··· 1 { 2 "name": "@streamplace/atproto-oauth-client-react-native", 3 - "version": "0.9.0", 4 "license": "MIT", 5 "description": "ATProto OAuth client for React Native", 6 "keywords": [
··· 1 { 2 "name": "@streamplace/atproto-oauth-client-react-native", 3 + "version": "0.9.9", 4 "license": "MIT", 5 "description": "ATProto OAuth client for React Native", 6 "keywords": [
js/components/assets/badges/live.png

This is a binary file and will not be displayed.

js/components/assets/badges/mod.png

This is a binary file and will not be displayed.

js/components/assets/badges/vip.png

This is a binary file and will not be displayed.

+13 -1
js/components/locales/en-US/common.ftl
··· 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 } ··· 60 [1] 1 viewer 61 *[other] { $count } viewers 62 }
··· 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 } ··· 60 [1] 1 viewer 61 *[other] { $count } viewers 62 } 63 + 64 + ## PDS Host Selector 65 + pds-selector-title = New to the Atmosphere? 66 + pds-selector-description = You'll need to select a PDS (Personal Data Server) to access apps on the Atmosphere, such as Bluesky, Tangled, and Spark. 67 + pds-selector-custom-label = Another PDS 68 + pds-selector-custom-description = Enter your own PDS host URL 69 + pds-selector-custom-url-label = Custom PDS URL 70 + pds-selector-custom-url-placeholder = https://pds.example.com 71 + pds-selector-learn-more = Learn more about self-hosting 72 + pds-selector-info = Each host has their own policies and reliability standards. Your ATProto data lives on the host you choose and you can migrate later. Note: Streamplace has its own moderation rules - you can be banned from Streamplace regardless of which host you choose. 73 + pds-selector-read-policies = Read { $label }'s <tosLink>Terms of Service</tosLink> and <privacyLink>Privacy Policy</privacyLink> before continuing. 74 + pds-selector-handle-policy-checkbox = I have read and agree to the <policyLink>handle policy</policyLink>
+1 -1
js/components/package.json
··· 1 { 2 "name": "@streamplace/components", 3 - "version": "0.9.7", 4 "description": "Streamplace React (Native) Components", 5 "main": "dist/index.js", 6 "types": "src/index.tsx",
··· 1 { 2 "name": "@streamplace/components", 3 + "version": "0.9.9", 4 "description": "Streamplace React (Native) Components", 5 "main": "dist/index.js", 6 "types": "src/index.tsx",
+27 -1
js/components/src/components/chat/chat-message.tsx
··· 4 Mention, 5 } from "@atproto/api/dist/client/types/app/bsky/richtext/facet"; 6 import { memo, useCallback } from "react"; 7 - import { Linking, View } from "react-native"; 8 import { ChatMessageViewHydrated } from "streamplace"; 9 import { RichtextSegment, segmentize } from "../../lib/facet"; 10 import { borders, flex, gap, ml, mr, opacity, pl } from "../../lib/theme/atoms"; ··· 23 }>; 24 } 25 26 import { useLivestreamStore } from "../../livestream-store"; 27 import { Text } from "../ui/text"; 28 ··· 164 style={{ 165 fontVariant: ["tabular-nums"], 166 color: colors.gray[400], 167 }} 168 > 169 {formatTime(item.record.createdAt)} 170 </Text> 171 )} 172 <Text 173 weight="bold" 174 color="default"
··· 4 Mention, 5 } from "@atproto/api/dist/client/types/app/bsky/richtext/facet"; 6 import { memo, useCallback } from "react"; 7 + import { Image, Linking, View } from "react-native"; 8 import { ChatMessageViewHydrated } from "streamplace"; 9 import { RichtextSegment, segmentize } from "../../lib/facet"; 10 import { borders, flex, gap, ml, mr, opacity, pl } from "../../lib/theme/atoms"; ··· 23 }>; 24 } 25 26 + import { zero } from "../.."; 27 import { useLivestreamStore } from "../../livestream-store"; 28 import { Text } from "../ui/text"; 29 ··· 165 style={{ 166 fontVariant: ["tabular-nums"], 167 color: colors.gray[400], 168 + width: 44, 169 }} 170 > 171 {formatTime(item.record.createdAt)} 172 </Text> 173 )} 174 + {item.badges?.length ? ( 175 + <View style={[zero.layout.flex.align.end]}> 176 + {item.badges.map((badge, index) => ( 177 + <View style={{ height: 3 }} key={`badge-${index}`}> 178 + {badge.badgeType === "place.stream.badge.defs#mod" ? ( 179 + <Image 180 + source={require("../../../assets/badges/mod.png")} 181 + style={{ height: 20, width: 20, marginTop: 3 }} 182 + /> 183 + ) : badge.badgeType === "place.stream.badge.defs#streamer" ? ( 184 + <Image 185 + source={require("../../../assets/badges/live.png")} 186 + style={{ height: 20, width: 20, marginTop: 3 }} 187 + /> 188 + ) : ( 189 + <Image 190 + source={require("../../../assets/badges/vip.png")} 191 + style={{ height: 20, width: 20, marginTop: 3 }} 192 + /> 193 + )} 194 + </View> 195 + ))} 196 + </View> 197 + ) : null} 198 <Text 199 weight="bold" 200 color="default"
+2 -2
js/components/src/components/chat/chat.tsx
··· 261 262 useEffect(() => { 263 buttonOpacity.value = withTiming(isScrolledUp ? 1 : 0, { duration: 200 }); 264 - buttonTranslateY.value = withTiming(isScrolledUp ? 0 : 20, { 265 duration: 200, 266 }); 267 }, [isScrolledUp]); ··· 345 onPress={scrollToBottom} 346 style={[ 347 { 348 - pointerEvents: "auto", 349 backgroundColor: theme.colors.primary, 350 opacity: 0.9, 351 borderRadius: 20,
··· 261 262 useEffect(() => { 263 buttonOpacity.value = withTiming(isScrolledUp ? 1 : 0, { duration: 200 }); 264 + buttonTranslateY.value = withTiming(isScrolledUp ? 0 : 50, { 265 duration: 200, 266 }); 267 }, [isScrolledUp]); ··· 345 onPress={scrollToBottom} 346 style={[ 347 { 348 + pointerEvents: isScrolledUp ? "auto" : "none", 349 backgroundColor: theme.colors.primary, 350 opacity: 0.9, 351 borderRadius: 20,
+1 -14
js/components/src/components/dashboard/header.tsx
··· 1 - import { AlertCircle, Car, Radio, Users } from "lucide-react-native"; 2 import { Pressable, Text, View } from "react-native"; 3 import * as zero from "../../ui"; 4 ··· 98 interface HeaderProps { 99 isLive: boolean; 100 streamTitle?: string; 101 - viewers?: number; 102 uptime?: string; 103 bitrate?: string; 104 timeBetweenSegments?: number; ··· 110 export default function Header({ 111 isLive, 112 streamTitle = "Live Stream", 113 - viewers = 0, 114 uptime = "00:00:00", 115 bitrate = "0 mbps", 116 timeBetweenSegments = 0, ··· 179 180 {/* Right side - Stream metrics */} 181 <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[6]]}> 182 - {isLive && ( 183 - <> 184 - <MetricItem 185 - icon={Users} 186 - label="Viewers" 187 - value={viewers.toLocaleString()} 188 - /> 189 - <MetricItem icon={Car} label="Bitrate" value={bitrate} /> 190 - </> 191 - )} 192 - 193 {!isLive && ( 194 <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[2]]}> 195 <Radio size={16} color="#6b7280" />
··· 1 + import { AlertCircle, Radio } from "lucide-react-native"; 2 import { Pressable, Text, View } from "react-native"; 3 import * as zero from "../../ui"; 4 ··· 98 interface HeaderProps { 99 isLive: boolean; 100 streamTitle?: string; 101 uptime?: string; 102 bitrate?: string; 103 timeBetweenSegments?: number; ··· 109 export default function Header({ 110 isLive, 111 streamTitle = "Live Stream", 112 uptime = "00:00:00", 113 bitrate = "0 mbps", 114 timeBetweenSegments = 0, ··· 177 178 {/* Right side - Stream metrics */} 179 <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[6]]}> 180 {!isLive && ( 181 <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[2]]}> 182 <Radio size={16} color="#6b7280" />
+2 -1
js/components/src/components/dashboard/information-widget.tsx
··· 12 import React, { useCallback, useEffect, useMemo, useState } from "react"; 13 import { LayoutChangeEvent, Text, TouchableOpacity, View } from "react-native"; 14 import Svg, { Path, Line as SvgLine, Text as SvgText } from "react-native-svg"; 15 import { 16 useLivestreamStore, 17 useSegment, ··· 38 const [bitrateHistory, setBitrateHistory] = useState<number[]>( 39 Array.from({ length: BITRATE_HISTORY_LENGTH }, () => 0), 40 ); 41 - const [showViewers, setShowViewers] = useState(false); 42 const [componentWidth, setComponentWidth] = useState<number>(220); 43 const [componentHeight, setComponentHeight] = useState<number>(400); 44 const [streamStartTime, setStreamStartTime] = useState<Date | null>(null);
··· 12 import React, { useCallback, useEffect, useMemo, useState } from "react"; 13 import { LayoutChangeEvent, Text, TouchableOpacity, View } from "react-native"; 14 import Svg, { Path, Line as SvgLine, Text as SvgText } from "react-native-svg"; 15 + import { useAQState } from "../../hooks"; 16 import { 17 useLivestreamStore, 18 useSegment, ··· 39 const [bitrateHistory, setBitrateHistory] = useState<number[]>( 40 Array.from({ length: BITRATE_HISTORY_LENGTH }, () => 0), 41 ); 42 + const [showViewers, setShowViewers] = useAQState("showViewers", true); 43 const [componentWidth, setComponentWidth] = useState<number>(220); 44 const [componentHeight, setComponentHeight] = useState<number>(400); 45 const [streamStartTime, setStreamStartTime] = useState<Date | null>(null);
+22 -1
js/components/src/components/mobile-player/player.tsx
··· 5 PlayerStatusTracker, 6 usePlayerStore, 7 } from "../../player-store"; 8 - import { useStreamplaceStore } from "../../streamplace-store"; 9 import { Text, View } from "../ui"; 10 import { Fullscreen } from "./fullscreen"; 11 import { PlayerProps } from "./props"; ··· 28 const reportModalOpen = usePlayerStore((x) => x.reportModalOpen); 29 const setReportModalOpen = usePlayerStore((x) => x.setReportModalOpen); 30 const reportSubject = usePlayerStore((x) => x.reportSubject); 31 32 useEffect(() => { 33 setReportingURL(props.reportingURL ?? null);
··· 5 PlayerStatusTracker, 6 usePlayerStore, 7 } from "../../player-store"; 8 + import { 9 + useMuted, 10 + useSetMuted, 11 + useStreamplaceStore, 12 + } from "../../streamplace-store"; 13 import { Text, View } from "../ui"; 14 import { Fullscreen } from "./fullscreen"; 15 import { PlayerProps } from "./props"; ··· 32 const reportModalOpen = usePlayerStore((x) => x.reportModalOpen); 33 const setReportModalOpen = usePlayerStore((x) => x.setReportModalOpen); 34 const reportSubject = usePlayerStore((x) => x.reportSubject); 35 + 36 + const setMuted = useSetMuted(); 37 + const muted = useMuted(); 38 + 39 + // if we set muted, set it and restore after 40 + useEffect(() => { 41 + let wasMuted: null | boolean = null; 42 + setTimeout(() => { 43 + if (props.muted != undefined) { 44 + wasMuted = muted; 45 + setMuted(props.muted); 46 + } 47 + }, 200); 48 + return () => { 49 + wasMuted !== null && setMuted(wasMuted); 50 + }; 51 + }, [props.muted]); 52 53 useEffect(() => { 54 setReportingURL(props.reportingURL ?? null);
+42 -8
js/components/src/components/mobile-player/ui/input.tsx
··· 9 setTitle: (title: string) => void; 10 ingestStarting: boolean; 11 toggleGoLive: () => void; 12 }; 13 14 export function InputPanel({ ··· 16 setTitle, 17 ingestStarting, 18 toggleGoLive, 19 }: InputPanelProps) { 20 const { slideKeyboard } = useKeyboardSlide(); 21 return ( ··· 37 { padding: 10 }, 38 ]} 39 > 40 - <View backgroundColor="rgba(64,64,64,0.8)" borderRadius={12}> 41 - <Input 42 - value={title} 43 - onChange={setTitle} 44 - placeholder="Enter stream title" 45 - onEndEditing={Keyboard.dismiss} 46 - /> 47 - </View> 48 {ingestStarting ? ( 49 <Text>Starting your stream...</Text> 50 ) : ( 51 <View style={[layout.flex.center]}> 52 <Pressable
··· 9 setTitle: (title: string) => void; 10 ingestStarting: boolean; 11 toggleGoLive: () => void; 12 + isLive: boolean; 13 + toggleStopStream?: () => void; 14 }; 15 16 export function InputPanel({ ··· 18 setTitle, 19 ingestStarting, 20 toggleGoLive, 21 + isLive, 22 + toggleStopStream, 23 }: InputPanelProps) { 24 const { slideKeyboard } = useKeyboardSlide(); 25 return ( ··· 41 { padding: 10 }, 42 ]} 43 > 44 + {!isLive && ( 45 + <View backgroundColor="rgba(64,64,64,0.8)" borderRadius={12}> 46 + <Input 47 + value={title} 48 + onChange={setTitle} 49 + placeholder="Enter stream title" 50 + onEndEditing={Keyboard.dismiss} 51 + /> 52 + </View> 53 + )} 54 {ingestStarting ? ( 55 <Text>Starting your stream...</Text> 56 + ) : isLive ? ( 57 + <View style={[layout.flex.center]}> 58 + <Pressable 59 + onPress={toggleStopStream} 60 + style={[ 61 + px[4], 62 + py[2], 63 + layout.flex.row, 64 + layout.flex.center, 65 + gap.all[1], 66 + { 67 + backgroundColor: "rgba(64,64,64, 0.8)", 68 + borderRadius: 12, 69 + }, 70 + ]} 71 + > 72 + <View 73 + style={[ 74 + p[2], 75 + { 76 + backgroundColor: "rgba(256,0,0, 0.8)", 77 + borderRadius: 12, 78 + }, 79 + ]} 80 + /> 81 + <Text center>Stop Stream</Text> 82 + </Pressable> 83 + </View> 84 ) : ( 85 <View style={[layout.flex.center]}> 86 <Pressable
+138 -2
js/components/src/components/mobile-player/ui/streamer-context-menu.tsx
··· 1 - export function StreamContextMenu() { 2 - return <></>; 3 }
··· 1 + import { ChevronRight, Cog } from "lucide-react-native"; 2 + import { useEffect, useState } from "react"; 3 + import Animated, { 4 + Easing, 5 + useAnimatedStyle, 6 + useSharedValue, 7 + withDelay, 8 + withSequence, 9 + withTiming, 10 + } from "react-native-reanimated"; 11 + import { useLivestreamInfo, zero } from "../../.."; 12 + import { usePlayerStore } from "../../../player-store"; 13 + import { 14 + DropdownMenu, 15 + DropdownMenuCheckboxItem, 16 + DropdownMenuGroup, 17 + DropdownMenuItem, 18 + DropdownMenuTrigger, 19 + ResponsiveDropdownMenuContent, 20 + Text, 21 + useTheme, 22 + } from "../../ui"; 23 + 24 + export function StreamContextMenu({ 25 + dropdownPortalContainer, 26 + }: { 27 + dropdownPortalContainer?: string; 28 + }) { 29 + const th = useTheme(); 30 + const debugInfo = usePlayerStore((x) => x.showDebugInfo); 31 + const setShowDebugInfo = usePlayerStore((x) => x.setShowDebugInfo); 32 + const { toggleStopStream } = useLivestreamInfo(); 33 + const ingest = usePlayerStore((x) => x.ingestConnectionState); 34 + const isLive = ingest !== null && ingest !== "new"; 35 + 36 + const [isOpen, setIsOpen] = useState(false); 37 + const [hasShownTooltip, setHasShownTooltip] = useState(false); 38 + 39 + const tooltipOpacity = useSharedValue(0); 40 + const tooltipTranslateX = useSharedValue(20); 41 + 42 + useEffect(() => { 43 + if (isLive && !hasShownTooltip) { 44 + tooltipOpacity.value = withDelay( 45 + 500, 46 + withSequence( 47 + withTiming(1, { duration: 300 }), 48 + withDelay(10000, withTiming(0, { duration: 300 })), 49 + ), 50 + ); 51 + tooltipTranslateX.value = withDelay( 52 + 500, 53 + withSequence( 54 + withTiming(0, { duration: 300 }), 55 + withDelay(10000, withTiming(20, { duration: 300 })), 56 + ), 57 + ); 58 + setHasShownTooltip(true); 59 + } 60 + }, [isLive, hasShownTooltip]); 61 + 62 + const iconRotate = useAnimatedStyle(() => { 63 + return { 64 + transform: [ 65 + { 66 + rotateZ: withTiming(isOpen ? "240deg" : "0deg", { 67 + duration: 650, 68 + easing: Easing.out(Easing.ease), 69 + }), 70 + }, 71 + ], 72 + }; 73 + }); 74 + 75 + const tooltipStyle = useAnimatedStyle(() => { 76 + return { 77 + opacity: tooltipOpacity.value, 78 + transform: [{ translateX: tooltipTranslateX.value }], 79 + }; 80 + }); 81 + 82 + return ( 83 + <DropdownMenu onOpenChange={setIsOpen} key={dropdownPortalContainer}> 84 + <DropdownMenuTrigger> 85 + <Animated.View style={[iconRotate]}> 86 + <Cog color={th.theme.colors.foreground} /> 87 + </Animated.View> 88 + <Animated.View 89 + style={[ 90 + tooltipStyle, 91 + { 92 + position: "absolute", 93 + right: 30, 94 + top: 0, 95 + backgroundColor: "rgba(64,64,64,0.95)", 96 + borderRadius: 8, 97 + paddingHorizontal: 8, 98 + paddingRight: 12, 99 + paddingVertical: 4, 100 + flexDirection: "row", 101 + alignItems: "center", 102 + gap: 6, 103 + zIndex: 9999999, 104 + pointerEvents: "box-none", 105 + width: 120, 106 + }, 107 + ]} 108 + > 109 + <Text size="sm" color="white"> 110 + End stream here 111 + </Text> 112 + <ChevronRight color="white" size={16} style={[zero.mr[4]]} /> 113 + </Animated.View> 114 + </DropdownMenuTrigger> 115 + <ResponsiveDropdownMenuContent side="top" align="end"> 116 + {isLive && ( 117 + <DropdownMenuGroup title="Stream"> 118 + <DropdownMenuItem 119 + closeOnPress={true} 120 + onPress={() => { 121 + toggleStopStream(); 122 + }} 123 + > 124 + <Text color="destructive">Stop Stream</Text> 125 + </DropdownMenuItem> 126 + </DropdownMenuGroup> 127 + )} 128 + <DropdownMenuGroup title="Advanced"> 129 + <DropdownMenuCheckboxItem 130 + checked={debugInfo} 131 + onCheckedChange={() => setShowDebugInfo(!debugInfo)} 132 + > 133 + <Text>Show Debug Info</Text> 134 + </DropdownMenuCheckboxItem> 135 + </DropdownMenuGroup> 136 + </ResponsiveDropdownMenuContent> 137 + </DropdownMenu> 138 + ); 139 }
+2
js/components/src/components/ui/textarea.tsx
··· 40 { borderRadius: 10 }, 41 style, 42 ]} 43 multiline={multiline} 44 numberOfLines={numberOfLines} 45 textAlignVertical="top"
··· 40 { borderRadius: 10 }, 41 style, 42 ]} 43 + autoComplete={props.autoComplete || "off"} 44 + textContentType={props.textContentType || "none"} 45 multiline={multiline} 46 numberOfLines={numberOfLines} 47 textAlignVertical="top"
+1
js/components/src/hooks/index.ts
··· 1 // barrel file :) 2 export * from "./useAvatars"; 3 export * from "./useCameraToggle"; 4 export * from "./useDocumentTitle";
··· 1 // barrel file :) 2 + export * from "./useAQState"; 3 export * from "./useAvatars"; 4 export * from "./useCameraToggle"; 5 export * from "./useDocumentTitle";
+37
js/components/src/hooks/useAQState.ts
···
··· 1 + import { useEffect, useState } from "react"; 2 + import storage from "../storage"; 3 + 4 + export function useAQState<T>( 5 + key: string, 6 + defaultValue: T, 7 + ): [T, (value: T) => void] { 8 + const [state, setState] = useState<T>(defaultValue); 9 + const [isLoaded, setIsLoaded] = useState(false); 10 + 11 + useEffect(() => { 12 + const loadFromStorage = async () => { 13 + try { 14 + const stored = await storage.getItem(key); 15 + if (stored !== null) { 16 + setState(JSON.parse(stored)); 17 + } 18 + } catch (error) { 19 + console.error(`Failed to load ${key} from storage:`, error); 20 + } finally { 21 + setIsLoaded(true); 22 + } 23 + }; 24 + loadFromStorage(); 25 + }, [key]); 26 + 27 + const setStoredState = (value: T) => { 28 + setState(value); 29 + if (isLoaded) { 30 + storage.setItem(key, JSON.stringify(value)).catch((error) => { 31 + console.error(`Failed to save ${key} to storage:`, error); 32 + }); 33 + } 34 + }; 35 + 36 + return [state, setStoredState]; 37 + }
+8
js/components/src/hooks/useLivestreamInfo.ts
··· 9 const ingestStarting = usePlayerStore((x) => x.ingestStarting); 10 const setIngestStarting = usePlayerStore((x) => x.setIngestStarting); 11 const setIngestLive = usePlayerStore((x) => x.setIngestLive); 12 13 const createStreamRecord = useCreateStreamRecord(); 14 ··· 54 } 55 }; 56 57 return { 58 ingest, 59 profile, ··· 67 setIngestStarting, 68 handleSubmit, 69 toggleGoLive, 70 }; 71 }
··· 9 const ingestStarting = usePlayerStore((x) => x.ingestStarting); 10 const setIngestStarting = usePlayerStore((x) => x.setIngestStarting); 11 const setIngestLive = usePlayerStore((x) => x.setIngestLive); 12 + const stopIngest = usePlayerStore((x) => x.stopIngest); 13 14 const createStreamRecord = useCreateStreamRecord(); 15 ··· 55 } 56 }; 57 58 + // Stop the current broadcast 59 + const toggleStopStream = () => { 60 + console.log("Stopping stream..."); 61 + stopIngest(); 62 + }; 63 + 64 return { 65 ingest, 66 profile, ··· 74 setIngestStarting, 75 handleSubmit, 76 toggleGoLive, 77 + toggleStopStream, 78 }; 79 }
+1
js/components/src/livestream-store/websocket-consumer.tsx
··· 80 chatProfile: (message as any).chatProfile, 81 replyTo: (message as any).replyTo, 82 deleted: message.deleted, 83 }; 84 state = reduceChat(state, [hydrated], [], []); 85 } else if (PlaceStreamSegment.isRecord(message)) {
··· 80 chatProfile: (message as any).chatProfile, 81 replyTo: (message as any).replyTo, 82 deleted: message.deleted, 83 + badges: message.badges, 84 }; 85 state = reduceChat(state, [hydrated], [], []); 86 } else if (PlaceStreamSegment.isRecord(message)) {
+3
js/components/src/player-store/player-state.tsx
··· 63 ingestAutoStart?: boolean; 64 setIngestAutoStart?: (autoStart: boolean) => void; 65 66 /** Timestamp (number) when ingest started, or null if not started */ 67 ingestStarted: number | null; 68
··· 63 ingestAutoStart?: boolean; 64 setIngestAutoStart?: (autoStart: boolean) => void; 65 66 + /** stop ingest process, again with a slight delay to allow UI to update */ 67 + stopIngest: () => void; 68 + 69 /** Timestamp (number) when ingest started, or null if not started */ 70 ingestStarted: number | null; 71
+17
js/components/src/player-store/player-store.tsx
··· 53 setIngestStarted: (timestamp: number | null) => 54 set(() => ({ ingestStarted: timestamp })), 55 56 fullscreen: false, 57 setFullscreen: (isFullscreen: boolean) => 58 set(() => ({ fullscreen: isFullscreen })),
··· 53 setIngestStarted: (timestamp: number | null) => 54 set(() => ({ ingestStarted: timestamp })), 55 56 + stopIngest: () => { 57 + set(() => ({ 58 + ingestLive: false, 59 + ingestConnectionState: "new", 60 + ingestStarted: null, 61 + })), 62 + setTimeout( 63 + () => 64 + set(() => ({ 65 + ingestLive: false, 66 + ingestConnectionState: "new", 67 + ingestStarted: null, 68 + })), 69 + 200, 70 + ); 71 + }, 72 + 73 fullscreen: false, 74 setFullscreen: (isFullscreen: boolean) => 75 set(() => ({ fullscreen: isFullscreen })),
+60 -1
js/components/src/streamplace-store/branding.tsx
··· 25 }); 26 }; 27 28 // hook to fetch broadcaster DID (unauthenticated) 29 export function useFetchBroadcasterDID() { 30 const streamplaceAgent = usePossiblyUnauthedPDSAgent(); 31 const store = getStreamplaceStoreFromContext(); 32 33 return useCallback(async () => { 34 try { ··· 140 141 // hook to get a specific branding asset by key 142 export function useBrandingAsset(key: string): BrandingAsset | undefined { 143 - return useStreamplaceStore((state) => state.branding?.[key]); 144 } 145 146 // convenience hook for main logo
··· 25 }); 26 }; 27 28 + const PropsInHeader = [ 29 + "siteTitle", 30 + "siteDescription", 31 + "primaryColor", 32 + "accentColor", 33 + "defaultStreamer", 34 + "mainLogo", 35 + "favicon", 36 + "sidebarBg", 37 + "legalLinks", 38 + ]; 39 + 40 + function getMetaContent(key: string): BrandingAsset | null { 41 + if (typeof window === "undefined" || !window.document) return null; 42 + const meta = document.querySelector(`meta[name="internal-brand:${key}`); 43 + if (meta && meta.getAttribute("content")) { 44 + let content = meta.getAttribute("content"); 45 + if (content) return JSON.parse(content) as BrandingAsset; 46 + } 47 + 48 + return null; 49 + } 50 + 51 // hook to fetch broadcaster DID (unauthenticated) 52 export function useFetchBroadcasterDID() { 53 const streamplaceAgent = usePossiblyUnauthedPDSAgent(); 54 const store = getStreamplaceStoreFromContext(); 55 + 56 + // prefetch from meta records, if on web 57 + useEffect(() => { 58 + if (typeof window !== "undefined" && window.document) { 59 + try { 60 + const metaRecords = PropsInHeader.reduce( 61 + (acc, key) => { 62 + const meta = document.querySelector( 63 + `meta[name="internal-brand:${key}`, 64 + ); 65 + // hrmmmmmmmmmmmm 66 + if (meta && meta.getAttribute("content")) { 67 + let content = meta.getAttribute("content"); 68 + if (content) acc[key] = JSON.parse(content) as BrandingAsset; 69 + } 70 + return acc; 71 + }, 72 + {} as Record<string, BrandingAsset>, 73 + ); 74 + 75 + console.log("Found meta records for broadcaster DID:", metaRecords); 76 + // filter out all non-text values, can get on second fetch? 77 + for (const key of Object.keys(metaRecords)) { 78 + if (metaRecords[key].mimeType != "text/plain") { 79 + delete metaRecords[key]; 80 + } 81 + } 82 + } catch (e) { 83 + console.warn("Failed to parse broadcaster DID from meta tags", e); 84 + } 85 + } 86 + }, []); 87 88 return useCallback(async () => { 89 try { ··· 195 196 // hook to get a specific branding asset by key 197 export function useBrandingAsset(key: string): BrandingAsset | undefined { 198 + return ( 199 + useStreamplaceStore((state) => state.branding?.[key]) || 200 + getMetaContent(key) || 201 + undefined 202 + ); 203 } 204 205 // convenience hook for main logo
+67 -36
js/docs/astro.config.mjs
··· 2 import starlight from "@astrojs/starlight"; 3 import { defineConfig, passthroughImageService } from "astro/config"; 4 import starlightOpenAPI, { openAPISidebarGroups } from "starlight-openapi"; 5 6 // https://astro.build/config 7 export default defineConfig({ ··· 32 }, 33 favicon: "/favicon.ico", 34 plugins: [ 35 starlightOpenAPI([ 36 { 37 - base: "api", 38 label: "Related XRPC API endpoints", 39 schema: "./src/content/docs/lex-reference/openapi.json", // or your json generated from swagger 40 sidebar: { ··· 45 }, 46 }, 47 ]), 48 - ], 49 - sidebar: [ 50 - { label: "โ† Back to Streamplace", link: "/back-to-home" }, 51 - { 52 - label: "How Streamplace Works (Blog)", 53 - link: "https://blog.stream.place/", 54 - attrs: { target: "_blank" }, 55 - }, 56 - { 57 - label: "Guides", 58 - items: [ 59 { 60 - label: "Start Streaming", 61 - autogenerate: { directory: "guides/start-streaming" }, 62 }, 63 { 64 - label: "Installing Streamplace", 65 - autogenerate: { directory: "guides/installing" }, 66 }, 67 { 68 - label: "Start Contributing", 69 - autogenerate: { directory: "guides/start-contributing" }, 70 }, 71 ], 72 - }, 73 - { 74 - label: "Features", 75 - autogenerate: { directory: "features" }, 76 - }, 77 - { 78 - label: "Video Metadata", 79 - autogenerate: { directory: "video-metadata" }, 80 - }, 81 - { 82 - label: "Components", 83 - autogenerate: { directory: "components" }, 84 - }, 85 - { 86 - label: "Lexicon Reference", 87 - autogenerate: { directory: "lex-reference" }, 88 - }, 89 - ...openAPISidebarGroups, 90 ], 91 }), 92 ],
··· 2 import starlight from "@astrojs/starlight"; 3 import { defineConfig, passthroughImageService } from "astro/config"; 4 import starlightOpenAPI, { openAPISidebarGroups } from "starlight-openapi"; 5 + import starlightSidebarSwipe from "starlight-sidebar-swipe"; 6 + import starlightSidebarTopics from "starlight-sidebar-topics"; 7 8 // https://astro.build/config 9 export default defineConfig({ ··· 34 }, 35 favicon: "/favicon.ico", 36 plugins: [ 37 + //starlightLinksValidator(), 38 + starlightSidebarSwipe(), 39 starlightOpenAPI([ 40 { 41 + base: "/api", 42 label: "Related XRPC API endpoints", 43 schema: "./src/content/docs/lex-reference/openapi.json", // or your json generated from swagger 44 sidebar: { ··· 49 }, 50 }, 51 ]), 52 + starlightSidebarTopics( 53 + [ 54 { 55 + label: "For Streamers & Viewers", 56 + link: "/", 57 + icon: "open-book", 58 + items: [ 59 + { 60 + label: "Start Streaming", 61 + autogenerate: { directory: "guides/start-streaming" }, 62 + }, 63 + { 64 + label: "Features", 65 + autogenerate: { directory: "features" }, 66 + }, 67 + ], 68 }, 69 { 70 + label: "For Developers", 71 + link: "/developers/", 72 + icon: "seti:config", 73 + id: "developers", 74 + items: [ 75 + { 76 + label: "Start Contributing", 77 + autogenerate: { directory: "guides/start-contributing" }, 78 + }, 79 + { 80 + label: "Installing Streamplace", 81 + autogenerate: { directory: "guides/installing" }, 82 + }, 83 + { 84 + label: "Features (Dev)", 85 + autogenerate: { directory: "features-dev" }, 86 + }, 87 + { 88 + label: "Video Metadata", 89 + autogenerate: { directory: "video-metadata" }, 90 + }, 91 + { 92 + label: "Components", 93 + autogenerate: { directory: "components" }, 94 + }, 95 + { 96 + label: "Localize Streamplace", 97 + autogenerate: { directory: "guides/localizing" }, 98 + }, 99 + ], 100 }, 101 { 102 + label: "API Reference", 103 + link: "/reference/", 104 + icon: "seti:json", 105 + id: "ref", 106 + items: [ 107 + { 108 + label: "Lexicon Reference", 109 + autogenerate: { directory: "lex-reference" }, 110 + }, 111 + ...openAPISidebarGroups, 112 + ], 113 }, 114 ], 115 + { 116 + topics: { 117 + ref: ["/api", "/api/**/*"], 118 + }, 119 + }, 120 + ), 121 ], 122 }), 123 ],
+8 -2
js/docs/package.json
··· 1 { 2 "name": "streamplace-docs", 3 "type": "module", 4 - "version": "0.9.8", 5 "scripts": { 6 "dev": "astro dev --host 0.0.0.0 --port 38082", 7 "start": "astro dev --host 0.0.0.0 --port 38082", ··· 15 "@streamplace/app": "workspace:*", 16 "astro": "^5.6.1", 17 "sharp": "^0.32.5", 18 "starlight-openapi": "^0.17.0", 19 "starlight-openapi-rapidoc": "^0.8.1-beta", 20 "streamplace": "workspace:*" 21 - } 22 }
··· 1 { 2 "name": "streamplace-docs", 3 "type": "module", 4 + "version": "0.9.9", 5 "scripts": { 6 "dev": "astro dev --host 0.0.0.0 --port 38082", 7 "start": "astro dev --host 0.0.0.0 --port 38082", ··· 15 "@streamplace/app": "workspace:*", 16 "astro": "^5.6.1", 17 "sharp": "^0.32.5", 18 + "starlight-links-validator": "^0.19.2", 19 "starlight-openapi": "^0.17.0", 20 "starlight-openapi-rapidoc": "^0.8.1-beta", 21 + "starlight-sidebar-swipe": "^0.1.1", 22 "streamplace": "workspace:*" 23 + }, 24 + "devDependencies": { 25 + "starlight-sidebar-topics": "^0.6.2" 26 + }, 27 + "private": true 28 }
+60
js/docs/src/components/HelpDesk.astro
···
··· 1 + --- 2 + import { Card, CardGrid } from "@astrojs/starlight/components"; 3 + 4 + interface Props { 5 + searchPlaceholder?: string; 6 + } 7 + --- 8 + 9 + <div class="helpdesk"> 10 + 11 + <h2>How can we help?</h2> 12 + <p>Search the knowledge base, or check out topics below.</p> 13 + 14 + <CardGrid> 15 + <Card title="Getting Started" icon="rocket"> 16 + <p>New to Streamplace? Start here to set up your first stream.</p> 17 + <ul> 18 + <li><a href="/docs/guides/start-streaming/quick-start">Quick start guide</a></li> 19 + <li><a href="/docs/guides/start-streaming/obs">Stream with OBS</a></li> 20 + </ul> 21 + </Card> 22 + 23 + <Card title="Developers & Self-Hosters" icon="laptop"> 24 + <p>Building with Streamplace or running your own node?</p> 25 + <ul> 26 + <li><a href="/docs/developers">Developer documentation</a></li> 27 + </ul> 28 + </Card> 29 + </CardGrid> 30 + </div> 31 + 32 + <style> 33 + .helpdesk { 34 + margin: 0 auto; 35 + } 36 + 37 + .helpdesk-search { 38 + margin-bottom: 2rem; 39 + } 40 + 41 + .search-input { 42 + width: 100%; 43 + padding: 1rem 1.5rem; 44 + font-size: 1.125rem; 45 + border: 2px solid var(--sl-color-gray-5); 46 + border-radius: 0.5rem; 47 + background: var(--sl-color-bg); 48 + color: var(--sl-color-text); 49 + transition: border-color 0.2s; 50 + } 51 + 52 + .search-input:focus { 53 + outline: none; 54 + border-color: var(--sl-color-accent); 55 + } 56 + 57 + .helpdesk h2 { 58 + margin-bottom: 1.5rem; 59 + } 60 + </style>
+1 -2
js/docs/src/content/docs/components/custom_ui.md
··· 1 --- 2 title: Creating your own player UI 3 - description: 4 - How to set up your player UI with components from @streamplace/components. 5 --- 6 7 # Building a Custom Player UI
··· 1 --- 2 title: Creating your own player UI 3 + description: How to set up your player UI with components from @streamplace/components. 4 --- 5 6 # Building a Custom Player UI
+40
js/docs/src/content/docs/developers.mdx
···
··· 1 + --- 2 + title: Developers & Self-Hosters 3 + description: Build with Streamplace or run your own infrastructure. 4 + template: doc 5 + --- 6 + 7 + import { Card, CardGrid } from "@astrojs/starlight/components"; 8 + 9 + ## Learn how to deploy, or contribute to Streamplace. 10 + 11 + <br /> 12 + 13 + <CardGrid stagger> 14 + <Card title="Building an Application" icon="laptop"> 15 + Integrate live video into your project. - [API 16 + reference](/docs/lex-reference/place-stream-defs) - [Our component 17 + library](/docs/components/custom_ui/) 18 + </Card> 19 + 20 + {" "} 21 + 22 + <Card title="Self-Hosting" icon="seti:config"> 23 + Run your own Streamplace infrastructure. - [Installation 24 + guide](/docs/guides/installing/installing-streamplace) 25 + </Card> 26 + 27 + {" "} 28 + 29 + <Card title="Contributing" icon="github"> 30 + Help improve Streamplace. - [Development 31 + setup](/docs/guides/streamplace-dev-setup) - [Video 32 + signing](/docs/video-metadata/intro/) 33 + </Card> 34 + 35 + <Card title="Support & Community" icon="information"> 36 + Get help and connect with other developers. - [GitHub 37 + issues](https://github.com/streamplace/streamplace/issues) - [Discord 38 + community](https://discord.stream.place) 39 + </Card> 40 + </CardGrid>
+3 -1
js/docs/src/content/docs/features/danmu.md
··· 3 description: Add flying bullet-style chat comments to the player, or your stream 4 --- 5 6 - :::note This feature is experimental and may change in future releases. ::: 7 8 [Danmu (or Danmaku)](https://en.wikipedia.org/wiki/Danmaku_subtitling) (ๅผนๅน•, 9 "bullet curtain") is a comment style where messages fly across the video
··· 3 description: Add flying bullet-style chat comments to the player, or your stream 4 --- 5 6 + :::note 7 + This feature is experimental and may change in future releases. 8 + ::: 9 10 [Danmu (or Danmaku)](https://en.wikipedia.org/wiki/Danmaku_subtitling) (ๅผนๅน•, 11 "bullet curtain") is a comment style where messages fly across the video
+27
js/docs/src/content/docs/features/embed.md
···
··· 1 + --- 2 + title: Embedding your livestream 3 + description: How to embed your livestream on your website, blog, etc. 4 + --- 5 + 6 + Streamplace provides an easy way to embed your livestream on any website or 7 + blog. 8 + 9 + You can access the embedded livestream page by putting `/embed` in the URL of 10 + your livestream. For example, if your livestream URL is 11 + `https://stream.place/iame.li`, the embed URL will be 12 + `https://stream.place/embed/iame.li`. 13 + 14 + You can use the following HTML snippet to embed your livestream: 15 + 16 + ```html 17 + <iframe 18 + src="https://stream.place/embed/your-handle" 19 + width="560" 20 + height="315" 21 + frameborder="0" 22 + allowfullscreen 23 + ></iframe> 24 + ``` 25 + 26 + Alternatively, you can use the share sheet located on your livestream page. 27 + Click the "Share" button, and you'll find the embed code ready to copy.
+52
js/docs/src/content/docs/features/multistreaming.md
···
··· 1 + --- 2 + title: Multistreaming 3 + description: Forward your Streamplace stream to other providers. 4 + --- 5 + 6 + :::note 7 + This guide isn't about setting up Streamplace as an OBS destination. See [OBS Multistreaming to Streamplace](/docs/guides/start-streaming/obs-multistreaming/) for information on that. 8 + ::: 9 + 10 + Multistreaming lets you forward your Streamplace stream to multiple platforms at the same time. Instead of streaming only to Streamplace, you can forward your stream to any platform that accepts RTMP input. 11 + 12 + ## Setting up multistream targets 13 + 14 + 1. Go to **Settings** > **Streaming** > **Multistream Targets** 15 + 2. Click **Create Multistream Target** 16 + 3. Enter the RTMP or RTMPS URL from your destination platform 17 + 4. Optionally give it a name to identify it later 18 + 5. Click **Create** 19 + 20 + ### Finding your multistream URL 21 + 22 + Different platforms will provide their own RTMP URLs. Some common examples: 23 + 24 + - **YouTube Live**: Format `rtmp://a.rtmp.youtube.com/live2/your-stream-key` 25 + - Find your stream key at https://studio.youtube.com/channel/UC/livestreaming (click the copy icon in the top right corner of the 'connect your encoder to go live' box) 26 + - **Twitch**: Format `rtmp://usw20.contribute.live-video.net/app/your-stream-key` 27 + - You can get a valid RTMPS url at https://help.twitch.tv/s/twitch-ingest-recommendation 28 + - Find your stream key at https://dashboard.twitch.tv/settings/stream (your 'primary stream key') 29 + 30 + :::note 31 + Your stream key should automatically be hidden once you confirm. Make sure you've entered it correctly! 32 + ::: 33 + 34 + ## Managing targets during a stream 35 + 36 + When you're live, you can see all your multistream targets on the Live Dashboard with their current status: 37 + 38 + - **Green (Active)**: Successfully streaming to this target 39 + - **Yellow (Pending)**: Connecting to this target 40 + - **Red (Error)**: Connection failed; check your URL and credentials 41 + - **Gray (Inactive)**: This target is disabled 42 + 43 + You can toggle any target on or off with the switch next to its name. Changes take effect immediately. 44 + 45 + ## Limits 46 + 47 + - **Maximum targets**: 100 total per account 48 + - **Maximum active targets**: 5 simultaneous streams 49 + 50 + ### Credits 51 + 52 + A portion of this documentation was taken from [ndroo.tv](https://bsky.app/profile/ndroo.tv)'s [guide on Streamplace](https://ndroo.tv/streamplace.html#2-configuring-your-account).
+83
js/docs/src/content/docs/features/webhooks.md
···
··· 1 + --- 2 + title: Discord Webhooks 3 + description: Configure Discord webhooks for livestream announcements and chat 4 + sidebar: 5 + order: 30 6 + --- 7 + 8 + Streamplace supports Discord webhooks for receiving livestream 9 + notifications and chat messages. You can create, manage, and configure webhooks 10 + to customize how events are delivered to your Discord channels. 11 + 12 + ## Webhook Events 13 + 14 + You can configure webhooks to listen for specific events. For right now, the 15 + following events are supported: 16 + 17 + - `Chat`: Triggered when a chat message is sent. 18 + - `Livestream`: Triggered when a livestream starts. 19 + 20 + ## Creating a Webhook 21 + 22 + To create a webhook, go to the "Settings" page of the Streamplace web app, then 23 + navigate to the "Webhooks" section. Click on "Create Webhook". The following 24 + fields are required: 25 + 26 + - Name: Webhook URL. For example, 27 + `https://discord.com/api/webhooks/{webhook.id}/{webhook.token}` 28 + - Events: Select the events you want to subscribe to (e.g., `Chat Messages`, 29 + `Livestream Started`). `Livestream Started` is pre-checked by default. 30 + 31 + We'd recommend also filling out these optional fields: 32 + 33 + - Name: A name for the webhook (e.g., "Discord Livestream Notifications") that 34 + you can remember. 35 + - Description: A description of what this webhook is for (e.g., "Sends 36 + livestream start notifications to Discord channel"). 37 + - Prefix: A prefix to add to each message sent by this webhook (e.g., 38 + "[Streamplace] "). Will apply to both Chat and Livestream events! 39 + - Suffix: A suffix to add to each message sent by this webhook (e.g., "is now 40 + live!"). Will apply to both Chat and Livestream events! 41 + - Text replacements: A list of text replacements to apply to chat messages sent 42 + by this webhook. Each replacement consists of a "from" string and a "to" 43 + string. For example, you could replace all instances of "foo" with "bar". 44 + 45 + After filling out the form, click "Create" to save your webhook. You should see 46 + it listed in the "Webhooks" section. 47 + 48 + ## Updating a Webhook 49 + 50 + To update a webhook, go to the "Settings" page of the Streamplace web app, then 51 + navigate to the "Webhooks" section. Find the webhook you want to update and 52 + click on the "pen" icon next to it. This will open the webhook edit form, where 53 + you can modify the fields as needed. After making your changes, click "Update" 54 + to save your changes. 55 + 56 + ## Deleting a Webhook 57 + 58 + To delete a webhook, go to the "Settings" page of the Streamplace web app, then 59 + navigate to the "Webhooks" section. Find the webhook you want to delete and 60 + click on the "trash" icon next to it. A confirmation dialog will appear; click 61 + "Delete" to confirm. The webhook will be removed from the list. 62 + 63 + ## Recommendations 64 + 65 + We'd recommend: 66 + 67 + - Creating separate Discord channels for livestream notifications and chat 68 + messages to keep them organized. 69 + - If you want to have one webhook for both chat and livestream events, you can 70 + create multiple webhooks with the same URL but different event subscriptions 71 + and prefixes/suffixes/replacements. 72 + - Testing your webhook by starting a livestream or sending a chat message to 73 + ensure that notifications are being sent correctly. 74 + 75 + ## API Documentation 76 + 77 + See these endpoint pages: 78 + 79 + - [Create Webhook](/docs/api/operations/placestreamservercreatewebhook) 80 + - [Get Webhook](/docs/api/operations/placestreamservergetwebhook) 81 + - [List Webhooks](/docs/api/operations/placestreamserverlistwebhooks) 82 + - [Update Webhook](/docs/api/operations/placestreamserverupdatewebhook) 83 + - [Delete Webhook](/docs/api/operations/placestreamserverdeletewebhook)
+39
js/docs/src/content/docs/features-dev/badges.md
···
··· 1 + --- 2 + title: badges system 3 + description: user badges for chat messages 4 + --- 5 + 6 + ## Overview 7 + 8 + Badges appear next to usernames in chat messages. they're small icons that indicate status (streamer, mod, vip, etc.). There will be max 3 badges shown at once. One of the badges is server-based (e.g. streamer, mod, node staff badge), but the other two can be selected from a pool of cosmetic badges (such as subscription badges, event badges et al.). These cosmetic badges are cryptographically signed by the issuing party, and all the user needs to do is apply them to their chat profile. Note that certain badges may appear/disappear based on the current streamer's chat tktk. 9 + 10 + ## Lexicon schemas 11 + 12 + We have three relevant lexicons. 13 + 14 + 1. **`place.stream.badge.defs`** - badge definitions and view model 15 + 16 + - defines known badge types: `mod`, `streamer`, `vip` 17 + - `badgeView` object: `{badgeType, issuer, recipient, signature?}` 18 + 19 + 2. **`place.stream.badge.issuance`** - record of badge grant 20 + 21 + - stored as atproto record (key: tid) 22 + - issued by streamer or other authorized entity 23 + - example: streamer issues vip badge to a user 24 + 25 + 3. **`place.stream.badge.display`** - user's badge selection 26 + - user-controlled record defining which badges to show 27 + - array of up to 3 `badgeSelection` objects 28 + - first slot server-controlled (mod/streamer/staff), second slot is streamer-specific (vip, subscription), third slot is user-set (event, staff2, node subscription, etc.) 29 + 30 + :::note 31 + This may get changed to be in the user's chat profile? Maybe we could have a "main" chat profile and a streamer-specific profile? 32 + ::: 33 + 34 + ## TODO 35 + 36 + - [ ] implement cryptographic signatures for badge issuance 37 + - [ ] implement badge issuance ui (streamer grants vip badges) 38 + - [ ] implement badge selection ui (users choose which badges to display) 39 + - [ ] add more badge types (subscriber, founder, staff, etc)
+1 -2
js/docs/src/content/docs/guides/start-contributing/styling-quick-reference.md
··· 1 --- 2 title: ZeroCSS Quick Reference 3 - description: 4 - Quick reference for Streamplace ZeroCSS - common patterns and utilities. 5 sidebar: 6 order: 31 7 ---
··· 1 --- 2 title: ZeroCSS Quick Reference 3 + description: Quick reference for Streamplace ZeroCSS - common patterns and utilities. 4 sidebar: 5 order: 31 6 ---
-83
js/docs/src/content/docs/guides/start-streaming/discord-hooks.md
··· 1 - --- 2 - title: Discord Webhooks 3 - description: Configure Discord webhooks for livestream announcements and chat 4 - sidebar: 5 - order: 30 6 - --- 7 - 8 - Streamplace supports Discord webhook integration for receiving livestream 9 - notifications and chat messages. You can create, manage, and configure webhooks 10 - to customize how events are delivered to your Discord channels. 11 - 12 - ## Webhook Events 13 - 14 - You can configure webhooks to listen for specific events. For right now, the 15 - following events are supported: 16 - 17 - - `Chat`: Triggered when a chat message is sent. 18 - - `Livestream`: Triggered when a livestream starts. 19 - 20 - ## Creating a Webhook 21 - 22 - To create a webhook, go to the "Settings" page of the Streamplace web app, then 23 - navigate to the "Webhooks" section. Click on "Create Webhook". The following 24 - fields are required: 25 - 26 - - Name: Webhook URL. For example, 27 - `https://discord.com/api/webhooks/{webhook.id}/{webhook.token}` 28 - - Events: Select the events you want to subscribe to (e.g., `Chat Messages`, 29 - `Livestream Started`). `Livestream Started` is pre-checked by default. 30 - 31 - We'd recommend also filling out these optional fields: 32 - 33 - - Name: A name for the webhook (e.g., "Discord Livestream Notifications") that 34 - you can remember. 35 - - Description: A description of what this webhook is for (e.g., "Sends 36 - livestream start notifications to Discord channel"). 37 - - Prefix: A prefix to add to each message sent by this webhook (e.g., 38 - "[Streamplace] "). Will apply to both Chat and Livestream events! 39 - - Suffix: A suffix to add to each message sent by this webhook (e.g., "is now 40 - live!"). Will apply to both Chat and Livestream events! 41 - - Text replacements: A list of text replacements to apply to chat messages sent 42 - by this webhook. Each replacement consists of a "from" string and a "to" 43 - string. For example, you could replace all instances of "foo" with "bar". 44 - 45 - After filling out the form, click "Create" to save your webhook. You should see 46 - it listed in the "Webhooks" section. 47 - 48 - ## Updating a Webhook 49 - 50 - To update a webhook, go to the "Settings" page of the Streamplace web app, then 51 - navigate to the "Webhooks" section. Find the webhook you want to update and 52 - click on the "pen" icon next to it. This will open the webhook edit form, where 53 - you can modify the fields as needed. After making your changes, click "Update" 54 - to save your changes. 55 - 56 - ## Deleting a Webhook 57 - 58 - To delete a webhook, go to the "Settings" page of the Streamplace web app, then 59 - navigate to the "Webhooks" section. Find the webhook you want to delete and 60 - click on the "trash" icon next to it. A confirmation dialog will appear; click 61 - "Delete" to confirm. The webhook will be removed from the list. 62 - 63 - ## Recommendations 64 - 65 - We'd recommend: 66 - 67 - - Creating separate Discord channels for livestream notifications and chat 68 - messages to keep them organized. 69 - - If you want to have one webhook for both chat and livestream events, you can 70 - create multiple webhooks with the same URL but different event subscriptions 71 - and prefixes/suffixes/replacements. 72 - - Testing your webhook by starting a livestream or sending a chat message to 73 - ensure that notifications are being sent correctly. 74 - 75 - ## API Documentation 76 - 77 - See these endpoint pages: 78 - 79 - - [Create Webhook](/docs/api/operations/placestreamservercreatewebhook) 80 - - [Get Webhook](/docs/api/operations/placestreamservergetwebhook) 81 - - [List Webhooks](/docs/api/operations/placestreamserverlistwebhooks) 82 - - [Update Webhook](/docs/api/operations/placestreamserverupdatewebhook) 83 - - [Delete Webhook](/docs/api/operations/placestreamserverdeletewebhook)
···
-27
js/docs/src/content/docs/guides/start-streaming/embed.md
··· 1 - --- 2 - title: Embedding your livestream 3 - description: How to embed your livestream on your website, blog, etc. 4 - --- 5 - 6 - Streamplace provides an easy way to embed your livestream on any website or 7 - blog. 8 - 9 - You can access the embedded livestream page by putting `/embed` in the URL of 10 - your livestream. For example, if your livestream URL is 11 - `https://stream.place/iame.li`, the embed URL will be 12 - `https://stream.place/embed/iame.li`. 13 - 14 - You can use the following HTML snippet to embed your livestream: 15 - 16 - ```html 17 - <iframe 18 - src="https://stream.place/embed/your-handle" 19 - width="560" 20 - height="315" 21 - frameborder="0" 22 - allowfullscreen 23 - ></iframe> 24 - ``` 25 - 26 - Alternatively, you can use the share sheet located on your livestream page. 27 - Click the "Share" button, and you'll find the embed code ready to copy.
···
+7 -1
js/docs/src/content/docs/guides/start-streaming/obs-multistreaming.md
··· 1 --- 2 - title: OBS Multistreaming with Streamplace 3 description: 4 Configure OBS for multistreaming to Streamplace and other platforms using the 5 obs-multi-rtmp plugin. 6 sidebar: 7 order: 20 8 --- 9 10 This guide explains how to configure Open Broadcaster Software (OBS) for 11 simultaneous streaming to Streamplace and other platforms using the
··· 1 --- 2 + title: OBS Multistreaming to Streamplace 3 description: 4 Configure OBS for multistreaming to Streamplace and other platforms using the 5 obs-multi-rtmp plugin. 6 sidebar: 7 order: 20 8 --- 9 + 10 + :::note 11 + This guide is not about the multistreaming feature. Check 12 + [the multistreaming guide](/docs/features/multistreaming) out for more 13 + information. 14 + ::: 15 16 This guide explains how to configure Open Broadcaster Software (OBS) for 17 simultaneous streaming to Streamplace and other platforms using the
+8 -1
js/docs/src/content/docs/guides/start-streaming/obs.md
··· 66 67 - Video Encoder: x264/h264 (**must** be an x/h.264 encoder) 68 - Rate Control: `CBR` 69 - - Keyframe Interval: `1s` 70 - This is _one keyframe per second_ 71 - In some situations (e.g. 'keyframe interval (**frames**)'), this should be 72 set to your FPS. 73 - x264 Options: `bframes=0` 74 - If available, there also may be a 'bframes' checkbox which should **NOT** be 75 checked 76 77 ### 3. Announce your stream 78 ··· 90 - [OBS Multistreaming Guide](guides/obs-multistreaming) 91 92 2. [**Aitum Multistream Plugin**](https://aitum.tv/products/multi) 93 94 ## Best Practices 95
··· 66 67 - Video Encoder: x264/h264 (**must** be an x/h.264 encoder) 68 - Rate Control: `CBR` 69 + - Keyframe Interval: `1s` (or anything less than once every ~7s) 70 - This is _one keyframe per second_ 71 - In some situations (e.g. 'keyframe interval (**frames**)'), this should be 72 set to your FPS. 73 - x264 Options: `bframes=0` 74 - If available, there also may be a 'bframes' checkbox which should **NOT** be 75 checked 76 + 77 + :::caution 78 + These last two options are very important! Your viewers' experience may be choppy or otherwise subpar if you don't have them correct. 79 + ::: 80 81 ### 3. Announce your stream 82 ··· 94 - [OBS Multistreaming Guide](guides/obs-multistreaming) 95 96 2. [**Aitum Multistream Plugin**](https://aitum.tv/products/multi) 97 + 98 + Alternatively, you can 99 + [multistream through Streamplace itself.](/docs/features/multistreaming) 100 101 ## Best Practices 102
+73
js/docs/src/content/docs/guides/start-streaming/quick-start.md
···
··· 1 + --- 2 + title: Quick Start 3 + description: Get up and streaming on Streamplace quickly. 4 + sidebar: 5 + order: 1 6 + --- 7 + 8 + This guide gets you from zero to streaming. If you get stuck, check out the full [OBS setup guide](/docs/guides/start-streaming/obs). 9 + 10 + :::tip 11 + You will want to check out our [community guidelines](https://blog.stream.place/3mcqwibo4ks2w) first for guidance on what you can and cannot do on Streamplace. 12 + ::: 13 + 14 + ## So, what is Streamplace? 15 + 16 + Streamplace is a video streaming service built on top of the AT Protocol (Authenticated Transfer Protocol), the same protocol Bluesky is built on. 17 + 18 + ## Step 1: Create your account 19 + 20 + 1. Go to [stream.place](https://stream.place) 21 + 2. Click "Sign in" in the top right. 22 + 3. Use your Atmosphere credentials to log in (ex. your Bluesky handle) 23 + - You'll need to use your actual password here - we're using OAuth so you enter your password on your PDS. We do not receive your password at all. 24 + 4. You're done! Your stream profile is live at `stream.place/your-handle` 25 + 26 + ## Step 2: Get your stream key 27 + 28 + 1. Click **Live Dashboard** (or go to [stream.place/dashboard](https://stream.place/dashboard)) 29 + 2. Click **Stream from OBS** 30 + 3. Click **Generate Stream Key** 31 + 4. Your key is copied to clipboard automatically 32 + 33 + Keep this key private. It's like a password, but for your stream. 34 + 35 + ## Step 3: Configure OBS 36 + 37 + Open OBS and go to **Settings โ†’ Stream**: 38 + 39 + - **Service**: `Custom...` 40 + - **Server**: `rtmps://stream.place:1935/live` 41 + - **Stream Key**: Paste what you copied in Step 2 42 + 43 + Then go to **Settings โ†’ Output โ†’ Streaming**: 44 + 45 + - **Video Encoder**: `libx264` (or `NVIDIA NVENC H.264` if you have an NVIDIA GPU) 46 + - **Rate Control**: `CBR` 47 + - **Bitrate**: `6000` Kbps (adjust down if you drop frames) 48 + - **Keyframe Interval**: `1` 49 + - **x264 Options**: `bframes=0`. If there's a 'bframes' option, you'll want to have that at '0' or unchecked. 50 + 51 + :::caution 52 + These last two options are very important! Your viewers' experience may be choppy or otherwise subpar if you don't have them correct. 53 + ::: 54 + 55 + ## Step 4: Go live 56 + 57 + 1. In OBS, click **Start Streaming** 58 + 2. Go back to the Live Dashboard at stream.place 59 + 3. Fill in your stream title and optionally pick a thumbnail8 60 + 4. If needed, turn on content warnings. ("Metadata" tab in Stream Settings) 61 + 5. Click **Announce Livestream** 62 + 6. Your stream is now live and visible to the world! 63 + 64 + ## Next steps 65 + 66 + - **Customize your chat**: Change your name color in Settings > Account 67 + - **Stream to other platforms too**: Set your Twitch/YouTube URLs in Settings > Multistream Targets to push your stream there automatically. See the [Multistreaming guide](/docs/features/multistreaming) for more information 68 + - **Improve stream quality**: See the [OBS guide](/docs/guides/start-streaming/obs) for encoder settings and troubleshooting 69 + - **Join the Discord!**: If you need any help, or just want to chat, check out our discord at https://discord.stream.place. 70 + 71 + ### Credits 72 + 73 + A portion of this documentation was taken from [ndroo.tv](https://bsky.app/profile/ndroo.tv)'s excellent [guide on Streamplace](https://ndroo.tv/streamplace.html#2-configuring-your-account).
+2 -32
js/docs/src/content/docs/index.mdx
··· 2 title: Welcome to Streamplace! 3 description: Begin your development journey with the Streamplace documentation. 4 template: doc 5 - hero: 6 - tagline: Solve live video for your project with Streamplace. 7 - image: 8 - file: ../../assets/cube.png 9 - alt: Streamplace logo. A pink 3d box viewed from a top corner. 10 - actions: 11 - - text: Get Started 12 - link: /docs/guides/start-streaming/obs 13 - icon: right-arrow 14 - - text: Visit Streamplace 15 - link: / 16 - icon: external 17 - variant: minimal 18 --- 19 20 - import { Card, CardGrid } from "@astrojs/starlight/components"; 21 - 22 - ## Next Steps 23 24 - <CardGrid> 25 - <Card title="Read the Docs" icon="open-book"> 26 - Learn how to start streaming with 27 - [Streamplace](/docs/guides/start-streaming/obs). 28 - </Card> 29 - <Card title="Install Streamplace" icon="download"> 30 - [Run your own Streamplace 31 - node](/docs/guides/installing/installing-streamplace). 32 - </Card> 33 - <Card title="API Reference" icon="document"> 34 - Explore the [Lexicon API reference](/docs/lex-reference/place-stream-defs). 35 - </Card> 36 - <Card title="Developer Setup" icon="setting"> 37 - Set up your [development environment](/docs/guides/streamplace-dev-setup). 38 - </Card> 39 - </CardGrid>
··· 2 title: Welcome to Streamplace! 3 description: Begin your development journey with the Streamplace documentation. 4 template: doc 5 --- 6 7 + import HelpDesk from "../../components/HelpDesk.astro"; 8 9 + <HelpDesk />
+108
js/docs/src/content/docs/lex-reference/badge/place-stream-badge-defs.md
···
··· 1 + --- 2 + title: place.stream.badge.defs 3 + description: Reference for the place.stream.badge.defs lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="badgeview"></a> 11 + 12 + ### `badgeView` 13 + 14 + **Type:** `object` 15 + 16 + View of a badge record, with fields resolved for display. If the DID in issuer is not the current streamplace node, the signature field shall be required. 17 + 18 + **Properties:** 19 + 20 + | Name | Type | Req'd | Description | Constraints | 21 + | ----------- | -------- | ----- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------- | 22 + | `badgeType` | `string` | โœ… | | Known Values: `place.stream.badge.defs#mod`, `place.stream.badge.defs#streamer` | 23 + | `issuer` | `string` | โœ… | DID of the badge issuer. | Format: `did` | 24 + | `recipient` | `string` | โœ… | DID of the badge recipient. | Format: `did` | 25 + | `signature` | `string` | โŒ | TODO: Cryptographic signature of the badge (of a place.stream.key). | | 26 + 27 + --- 28 + 29 + <a name="mod"></a> 30 + 31 + ### `mod` 32 + 33 + **Type:** `token` 34 + 35 + This user is a moderator. Displayed with a sword icon. 36 + 37 + --- 38 + 39 + <a name="streamer"></a> 40 + 41 + ### `streamer` 42 + 43 + **Type:** `token` 44 + 45 + This user is the streamer. Displayed with a star icon. 46 + 47 + --- 48 + 49 + <a name="vip"></a> 50 + 51 + ### `vip` 52 + 53 + **Type:** `token` 54 + 55 + This user is a very important person. 56 + 57 + --- 58 + 59 + ## Lexicon Source 60 + 61 + ```json 62 + { 63 + "lexicon": 1, 64 + "id": "place.stream.badge.defs", 65 + "defs": { 66 + "badgeView": { 67 + "type": "object", 68 + "required": ["badgeType", "issuer", "recipient"], 69 + "description": "View of a badge record, with fields resolved for display. If the DID in issuer is not the current streamplace node, the signature field shall be required.", 70 + "properties": { 71 + "badgeType": { 72 + "type": "string", 73 + "knownValues": [ 74 + "place.stream.badge.defs#mod", 75 + "place.stream.badge.defs#streamer" 76 + ] 77 + }, 78 + "issuer": { 79 + "type": "string", 80 + "format": "did", 81 + "description": "DID of the badge issuer." 82 + }, 83 + "recipient": { 84 + "type": "string", 85 + "format": "did", 86 + "description": "DID of the badge recipient." 87 + }, 88 + "signature": { 89 + "type": "string", 90 + "description": "TODO: Cryptographic signature of the badge (of a place.stream.key)." 91 + } 92 + } 93 + }, 94 + "mod": { 95 + "type": "token", 96 + "description": "This user is a moderator. Displayed with a sword icon." 97 + }, 98 + "streamer": { 99 + "type": "token", 100 + "description": "This user is the streamer. Displayed with a star icon." 101 + }, 102 + "vip": { 103 + "type": "token", 104 + "description": "This user is a very important person." 105 + } 106 + } 107 + } 108 + ```
+90
js/docs/src/content/docs/lex-reference/badge/place-stream-badge-display.md
···
··· 1 + --- 2 + title: place.stream.badge.display 3 + description: Reference for the place.stream.badge.display 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 + Record issuing a badge to a user. 17 + 18 + **Record Properties:** 19 + 20 + | Name | Type | Req'd | Description | Constraints | 21 + | -------- | --------------------------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------- | ------------ | 22 + | `badges` | Array of [`#badgeSelection`](#badgeselection) | โœ… | Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable. | Max Items: 3 | 23 + 24 + --- 25 + 26 + <a name="badgeselection"></a> 27 + 28 + ### `badgeSelection` 29 + 30 + **Type:** `object` 31 + 32 + A badge selected for display. May be a full badgeView from the server, or a token representing a badge type that the client can look up for display info. 33 + 34 + **Properties:** 35 + 36 + | Name | Type | Req'd | Description | Constraints | 37 + | ----------- | -------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | 38 + | `badgeType` | `string` | โœ… | | Known Values: `place.stream.badge.defs#mod`, `place.stream.badge.defs#vip` | 39 + | `issuance` | `string` | โŒ | URI of the badge issuance record (place.stream.badge.issuance) that represents this badge. Required if badgeType is not recognized. | Format: `at-uri` | 40 + 41 + --- 42 + 43 + ## Lexicon Source 44 + 45 + ```json 46 + { 47 + "lexicon": 1, 48 + "id": "place.stream.badge.display", 49 + "defs": { 50 + "main": { 51 + "type": "record", 52 + "description": "Record issuing a badge to a user.", 53 + "record": { 54 + "type": "object", 55 + "required": ["badges"], 56 + "properties": { 57 + "badges": { 58 + "type": "array", 59 + "description": "Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable.", 60 + "maxLength": 3, 61 + "items": { 62 + "type": "ref", 63 + "ref": "#badgeSelection" 64 + } 65 + } 66 + } 67 + } 68 + }, 69 + "badgeSelection": { 70 + "type": "object", 71 + "description": "A badge selected for display. May be a full badgeView from the server, or a token representing a badge type that the client can look up for display info.", 72 + "required": ["badgeType"], 73 + "properties": { 74 + "badgeType": { 75 + "type": "string", 76 + "knownValues": [ 77 + "place.stream.badge.defs#mod", 78 + "place.stream.badge.defs#vip" 79 + ] 80 + }, 81 + "issuance": { 82 + "type": "string", 83 + "format": "at-uri", 84 + "description": "URI of the badge issuance record (place.stream.badge.issuance) that represents this badge. Required if badgeType is not recognized." 85 + } 86 + } 87 + } 88 + } 89 + } 90 + ```
+63
js/docs/src/content/docs/lex-reference/badge/place-stream-badge-issuance.md
···
··· 1 + --- 2 + title: place.stream.badge.issuance 3 + description: Reference for the place.stream.badge.issuance 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 + Record issuing a badge to a user. 17 + 18 + **Record Key:** `tid` 19 + 20 + **Record Properties:** 21 + 22 + | Name | Type | Req'd | Description | Constraints | 23 + | ----------- | -------- | ----- | ------------------------------------------------------------------- | ------------------------------------------- | 24 + | `badgeType` | `string` | โœ… | | Known Values: `place.stream.badge.defs#vip` | 25 + | `recipient` | `string` | โœ… | DID of the badge recipient. | Format: `did` | 26 + | `signature` | `string` | โœ… | TODO: Cryptographic signature of the badge (of a place.stream.key). | | 27 + 28 + --- 29 + 30 + ## Lexicon Source 31 + 32 + ```json 33 + { 34 + "lexicon": 1, 35 + "id": "place.stream.badge.issuance", 36 + "defs": { 37 + "main": { 38 + "type": "record", 39 + "key": "tid", 40 + "description": "Record issuing a badge to a user.", 41 + "record": { 42 + "type": "object", 43 + "required": ["badgeType", "recipient", "signature"], 44 + "properties": { 45 + "badgeType": { 46 + "type": "string", 47 + "knownValues": ["place.stream.badge.defs#vip"] 48 + }, 49 + "recipient": { 50 + "type": "string", 51 + "format": "did", 52 + "description": "DID of the badge recipient." 53 + }, 54 + "signature": { 55 + "type": "string", 56 + "description": "TODO: Cryptographic signature of the badge (of a place.stream.key)." 57 + } 58 + } 59 + } 60 + } 61 + } 62 + } 63 + ```
+2 -1
js/docs/src/content/docs/lex-reference/branding/place-stream-branding-getblob.md
··· 28 - **Description:** Raw blob data with appropriate content-type 29 - **Schema:** 30 31 - _Schema not defined._ **Possible Errors:** 32 33 - `BrandingNotFound`: The requested branding asset does not exist 34
··· 28 - **Description:** Raw blob data with appropriate content-type 29 - **Schema:** 30 31 + _Schema not defined._ 32 + **Possible Errors:** 33 34 - `BrandingNotFound`: The requested branding asset does not exist 35
+1 -2
js/docs/src/content/docs/lex-reference/broadcast/place-stream-broadcast-origin.md
··· 13 14 **Type:** `record` 15 16 - Record indicating a livestream is published and available for replication at a 17 - given address. By convention, the record key is streamer::server 18 19 **Record Key:** `any` 20
··· 13 14 **Type:** `record` 15 16 + Record indicating a livestream is published and available for replication at a given address. By convention, the record key is streamer::server 17 18 **Record Key:** `any` 19
+1 -2
js/docs/src/content/docs/lex-reference/broadcast/place-stream-broadcast-syndication.md
··· 13 14 **Type:** `record` 15 16 - Record created by a Streamplace broadcaster to indicate that they will be 17 - replicating a livestream. NYI 18 19 **Record Key:** `tid` 20
··· 13 14 **Type:** `record` 15 16 + Record created by a Streamplace broadcaster to indicate that they will be replicating a livestream. NYI 17 18 **Record Key:** `tid` 19
+20 -10
js/docs/src/content/docs/lex-reference/chat/place-stream-chat-defs.md
··· 15 16 **Properties:** 17 18 - | Name | Type | Req'd | Description | Constraints | 19 - | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | -------------------------------------------------------------------------------------- | ------------------ | 20 - | `uri` | `string` | โœ… | | Format: `at-uri` | 21 - | `cid` | `string` | โœ… | | Format: `cid` | 22 - | `author` | [`app.bsky.actor.defs#profileViewBasic`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/actor/defs.json#profileViewBasic) | โœ… | | | 23 - | `record` | `unknown` | โœ… | | | 24 - | `indexedAt` | `string` | โœ… | | Format: `datetime` | 25 - | `chatProfile` | [`place.stream.chat.profile`](/lex-reference/place-stream-chat-profile) | โŒ | | | 26 - | `replyTo` | Union of:<br/>&nbsp;&nbsp;[`#messageView`](#messageview) | โŒ | | | 27 - | `deleted` | `boolean` | โŒ | If true, this message has been deleted or labeled and should be cleared from the cache | | 28 29 --- 30 ··· 69 "deleted": { 70 "type": "boolean", 71 "description": "If true, this message has been deleted or labeled and should be cleared from the cache" 72 } 73 } 74 }
··· 15 16 **Properties:** 17 18 + | Name | Type | Req'd | Description | Constraints | 19 + | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | 20 + | `uri` | `string` | โœ… | | Format: `at-uri` | 21 + | `cid` | `string` | โœ… | | Format: `cid` | 22 + | `author` | [`app.bsky.actor.defs#profileViewBasic`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/actor/defs.json#profileViewBasic) | โœ… | | | 23 + | `record` | `unknown` | โœ… | | | 24 + | `indexedAt` | `string` | โœ… | | Format: `datetime` | 25 + | `chatProfile` | [`place.stream.chat.profile`](/lex-reference/place-stream-chat-profile) | โŒ | | | 26 + | `replyTo` | Union of:<br/>&nbsp;&nbsp;[`#messageView`](#messageview) | โŒ | | | 27 + | `deleted` | `boolean` | โŒ | If true, this message has been deleted or labeled and should be cleared from the cache | | 28 + | `badges` | Array of [`place.stream.badge.defs#badgeView`](/lex-reference/place-stream-badge-defs#badgeview) | โŒ | Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable. Tokens are looked up in badges.json for display info. | Max Items: 3 | 29 30 --- 31 ··· 70 "deleted": { 71 "type": "boolean", 72 "description": "If true, this message has been deleted or labeled and should be cleared from the cache" 73 + }, 74 + "badges": { 75 + "type": "array", 76 + "description": "Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable. Tokens are looked up in badges.json for display info.", 77 + "maxLength": 3, 78 + "items": { 79 + "type": "ref", 80 + "ref": "place.stream.badge.defs#badgeView" 81 + } 82 } 83 } 84 }
+2 -1
js/docs/src/content/docs/lex-reference/live/place-stream-live-getprofilecard.md
··· 26 - **Encoding:** `*/*` 27 - **Schema:** 28 29 - _Schema not defined._ **Possible Errors:** 30 31 - `RepoNotFound` 32
··· 26 - **Encoding:** `*/*` 27 - **Schema:** 28 29 + _Schema not defined._ 30 + **Possible Errors:** 31 32 - `RepoNotFound` 33
+1 -2
js/docs/src/content/docs/lex-reference/live/place-stream-live-searchactorstypeahead.md
··· 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
··· 13 14 **Type:** `query` 15 16 + Find actor suggestions for a prefix search term. Expected use is for auto-completion during text field entry. 17 18 **Parameters:** 19
+1 -2
js/docs/src/content/docs/lex-reference/metadata/place-stream-metadata-configuration.md
··· 13 14 **Type:** `record` 15 16 - Default metadata record for livestream including content warnings, rights, and 17 - distribution policy 18 19 **Record Key:** `literal:self` 20
··· 13 14 **Type:** `record` 15 16 + Default metadata record for livestream including content warnings, rights, and distribution policy 17 18 **Record Key:** `literal:self` 19
+8 -19
js/docs/src/content/docs/lex-reference/metadata/place-stream-metadata-contentrights.md
··· 33 34 **Type:** `token` 35 36 - All rights reserved to the creator โ€” others cannot use, modify, or share without 37 - explicit authorization. 38 39 --- 40 ··· 44 45 **Type:** `token` 46 47 - Public domain dedication. You waive all copyright and related rights where 48 - possible. Others may copy, modify, distribute, or perform your work for any 49 - purpose without attribution. 50 51 --- 52 ··· 56 57 **Type:** `token` 58 59 - Attribution required. Others may copy, distribute, remix, and build upon your 60 - work, even commercially, if they credit you. 61 62 --- 63 ··· 67 68 **Type:** `token` 69 70 - Attribution + share-alike. Others may adapt and build upon your work, even 71 - commercially, if they credit you and license their new creations under identical 72 - terms. 73 74 --- 75 ··· 79 80 **Type:** `token` 81 82 - Attribution + non-commercial. Others may adapt and build upon your work for 83 - non-commercial purposes only, and must credit you. 84 85 --- 86 ··· 90 91 **Type:** `token` 92 93 - Attribution + non-commercial + share-alike. Others may adapt and build upon your 94 - work for non-commercial purposes only, must credit you, and must license their 95 - new creations under identical terms. 96 97 --- 98 ··· 102 103 **Type:** `token` 104 105 - Attribution + no derivatives. Others may reuse your work, even commercially, but 106 - it must remain unchanged and you must be credited. 107 108 --- 109 ··· 113 114 **Type:** `token` 115 116 - Attribution + non-commercial + no derivatives. Others may download and share 117 - your work with credit, but cannot change it or use it commercially. 118 119 --- 120
··· 33 34 **Type:** `token` 35 36 + All rights reserved to the creator โ€” others cannot use, modify, or share without explicit authorization. 37 38 --- 39 ··· 43 44 **Type:** `token` 45 46 + Public domain dedication. You waive all copyright and related rights where possible. Others may copy, modify, distribute, or perform your work for any purpose without attribution. 47 48 --- 49 ··· 53 54 **Type:** `token` 55 56 + Attribution required. Others may copy, distribute, remix, and build upon your work, even commercially, if they credit you. 57 58 --- 59 ··· 63 64 **Type:** `token` 65 66 + Attribution + share-alike. Others may adapt and build upon your work, even commercially, if they credit you and license their new creations under identical terms. 67 68 --- 69 ··· 73 74 **Type:** `token` 75 76 + Attribution + non-commercial. Others may adapt and build upon your work for non-commercial purposes only, and must credit you. 77 78 --- 79 ··· 83 84 **Type:** `token` 85 86 + Attribution + non-commercial + share-alike. Others may adapt and build upon your work for non-commercial purposes only, must credit you, and must license their new creations under identical terms. 87 88 --- 89 ··· 93 94 **Type:** `token` 95 96 + Attribution + no derivatives. Others may reuse your work, even commercially, but it must remain unchanged and you must be credited. 97 98 --- 99 ··· 103 104 **Type:** `token` 105 106 + Attribution + non-commercial + no derivatives. Others may download and share your work with credit, but cannot change it or use it commercially. 107 108 --- 109
+8 -18
js/docs/src/content/docs/lex-reference/metadata/place-stream-metadata-contentwarnings.md
··· 29 30 **Type:** `token` 31 32 - The content could be perceived as offensive due to the discussion or display of 33 - death. 34 35 --- 36 ··· 40 41 **Type:** `token` 42 43 - The content contains a portrayal of the use or abuse of mind altering 44 - substances. 45 46 --- 47 ··· 51 52 **Type:** `token` 53 54 - The content contains violent actions of a fantasy nature, involving human or 55 - non-human characters in situations easily distinguishable from real life. 56 57 --- 58 ··· 62 63 **Type:** `token` 64 65 - The content contains flashing lights that could be harmful to viewers with 66 - seizure disorders such as photosensitive epilepsy. 67 68 --- 69 ··· 93 94 **Type:** `token` 95 96 - The content contains information that can be used to identify a particular 97 - individual, such as a name, phone number, email address, physical address, or IP 98 - address. 99 100 --- 101 ··· 105 106 **Type:** `token` 107 108 - The content could be perceived as offensive due to the discussion or display of 109 - sexuality. 110 111 --- 112 ··· 116 117 **Type:** `token` 118 119 - The content could be perceived as distressing due to the discussion or display 120 - of suffering or triggering topics, including suicide, eating disorders or self 121 - harm. 122 123 --- 124 ··· 128 129 **Type:** `token` 130 131 - The content could be perceived as offensive due to the discussion or display of 132 - violence. 133 134 --- 135
··· 29 30 **Type:** `token` 31 32 + The content could be perceived as offensive due to the discussion or display of death. 33 34 --- 35 ··· 39 40 **Type:** `token` 41 42 + The content contains a portrayal of the use or abuse of mind altering substances. 43 44 --- 45 ··· 49 50 **Type:** `token` 51 52 + The content contains violent actions of a fantasy nature, involving human or non-human characters in situations easily distinguishable from real life. 53 54 --- 55 ··· 59 60 **Type:** `token` 61 62 + The content contains flashing lights that could be harmful to viewers with seizure disorders such as photosensitive epilepsy. 63 64 --- 65 ··· 89 90 **Type:** `token` 91 92 + The content contains information that can be used to identify a particular individual, such as a name, phone number, email address, physical address, or IP address. 93 94 --- 95 ··· 99 100 **Type:** `token` 101 102 + The content could be perceived as offensive due to the discussion or display of sexuality. 103 104 --- 105 ··· 109 110 **Type:** `token` 111 112 + The content could be perceived as distressing due to the discussion or display of suffering or triggering topics, including suicide, eating disorders or self harm. 113 114 --- 115 ··· 119 120 **Type:** `token` 121 122 + The content could be perceived as offensive due to the discussion or display of violence. 123 124 --- 125
+3 -6
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-createblock.md
··· 13 14 **Type:** `procedure` 15 16 - Create a block (ban) on behalf of a streamer. Requires 'ban' permission. Creates 17 - an app.bsky.graph.block record in the streamer's repository. 18 19 **Parameters:** _(None defined)_ 20 ··· 46 **Possible Errors:** 47 48 - `Unauthorized`: The request lacks valid authentication credentials. 49 - - `Forbidden`: The caller does not have permission to create blocks for this 50 - streamer. 51 - - `SessionNotFound`: The streamer's OAuth session could not be found or is 52 - invalid. 53 54 --- 55
··· 13 14 **Type:** `procedure` 15 16 + Create a block (ban) on behalf of a streamer. Requires 'ban' permission. Creates an app.bsky.graph.block record in the streamer's repository. 17 18 **Parameters:** _(None defined)_ 19 ··· 45 **Possible Errors:** 46 47 - `Unauthorized`: The request lacks valid authentication credentials. 48 + - `Forbidden`: The caller does not have permission to create blocks for this streamer. 49 + - `SessionNotFound`: The streamer's OAuth session could not be found or is invalid. 50 51 --- 52
+3 -7
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-creategate.md
··· 13 14 **Type:** `procedure` 15 16 - Create a gate (hide message) on behalf of a streamer. Requires 'hide' 17 - permission. Creates a place.stream.chat.gate record in the streamer's 18 - repository. 19 20 **Parameters:** _(None defined)_ 21 ··· 46 **Possible Errors:** 47 48 - `Unauthorized`: The request lacks valid authentication credentials. 49 - - `Forbidden`: The caller does not have permission to hide messages for this 50 - streamer. 51 - - `SessionNotFound`: The streamer's OAuth session could not be found or is 52 - invalid. 53 54 --- 55
··· 13 14 **Type:** `procedure` 15 16 + Create a gate (hide message) on behalf of a streamer. Requires 'hide' permission. Creates a place.stream.chat.gate record in the streamer's repository. 17 18 **Parameters:** _(None defined)_ 19 ··· 44 **Possible Errors:** 45 46 - `Unauthorized`: The request lacks valid authentication credentials. 47 + - `Forbidden`: The caller does not have permission to hide messages for this streamer. 48 + - `SessionNotFound`: The streamer's OAuth session could not be found or is invalid. 49 50 --- 51
+5 -7
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-deleteblock.md
··· 13 14 **Type:** `procedure` 15 16 - Delete a block (unban) on behalf of a streamer. Requires 'ban' permission. 17 - Deletes an app.bsky.graph.block record from the streamer's repository. 18 19 **Parameters:** _(None defined)_ 20 ··· 37 38 **Schema Type:** `object` 39 40 - _(No properties defined)_ **Possible Errors:** 41 42 - `Unauthorized`: The request lacks valid authentication credentials. 43 - - `Forbidden`: The caller does not have permission to delete blocks for this 44 - streamer. 45 - - `SessionNotFound`: The streamer's OAuth session could not be found or is 46 - invalid. 47 48 --- 49
··· 13 14 **Type:** `procedure` 15 16 + Delete a block (unban) on behalf of a streamer. Requires 'ban' permission. Deletes an app.bsky.graph.block record from the streamer's repository. 17 18 **Parameters:** _(None defined)_ 19 ··· 36 37 **Schema Type:** `object` 38 39 + _(No properties defined)_ 40 + **Possible Errors:** 41 42 - `Unauthorized`: The request lacks valid authentication credentials. 43 + - `Forbidden`: The caller does not have permission to delete blocks for this streamer. 44 + - `SessionNotFound`: The streamer's OAuth session could not be found or is invalid. 45 46 --- 47
+5 -8
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-deletegate.md
··· 13 14 **Type:** `procedure` 15 16 - Delete a gate (unhide message) on behalf of a streamer. Requires 'hide' 17 - permission. Deletes a place.stream.chat.gate record from the streamer's 18 - repository. 19 20 **Parameters:** _(None defined)_ 21 ··· 38 39 **Schema Type:** `object` 40 41 - _(No properties defined)_ **Possible Errors:** 42 43 - `Unauthorized`: The request lacks valid authentication credentials. 44 - - `Forbidden`: The caller does not have permission to unhide messages for this 45 - streamer. 46 - - `SessionNotFound`: The streamer's OAuth session could not be found or is 47 - invalid. 48 49 --- 50
··· 13 14 **Type:** `procedure` 15 16 + Delete a gate (unhide message) on behalf of a streamer. Requires 'hide' permission. Deletes a place.stream.chat.gate record from the streamer's repository. 17 18 **Parameters:** _(None defined)_ 19 ··· 36 37 **Schema Type:** `object` 38 39 + _(No properties defined)_ 40 + **Possible Errors:** 41 42 - `Unauthorized`: The request lacks valid authentication credentials. 43 + - `Forbidden`: The caller does not have permission to unhide messages for this streamer. 44 + - `SessionNotFound`: The streamer's OAuth session could not be found or is invalid. 45 46 --- 47
+3 -7
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-updatelivestream.md
··· 13 14 **Type:** `procedure` 15 16 - Update livestream metadata on behalf of a streamer. Requires 'livestream.manage' 17 - permission. Updates a place.stream.livestream record in the streamer's 18 - repository. 19 20 **Parameters:** _(None defined)_ 21 ··· 47 **Possible Errors:** 48 49 - `Unauthorized`: The request lacks valid authentication credentials. 50 - - `Forbidden`: The caller does not have permission to update livestream metadata 51 - for this streamer. 52 - - `SessionNotFound`: The streamer's OAuth session could not be found or is 53 - invalid. 54 - `RecordNotFound`: The specified livestream record does not exist. 55 56 ---
··· 13 14 **Type:** `procedure` 15 16 + Update livestream metadata on behalf of a streamer. Requires 'livestream.manage' permission. Updates a place.stream.livestream record in the streamer's repository. 17 18 **Parameters:** _(None defined)_ 19 ··· 45 **Possible Errors:** 46 47 - `Unauthorized`: The request lacks valid authentication credentials. 48 + - `Forbidden`: The caller does not have permission to update livestream metadata for this streamer. 49 + - `SessionNotFound`: The streamer's OAuth session could not be found or is invalid. 50 - `RecordNotFound`: The specified livestream record does not exist. 51 52 ---
+1 -2
js/docs/src/content/docs/lex-reference/multistream/place-stream-multistream-createtarget.md
··· 33 - **Encoding:** `application/json` 34 - **Schema:** 35 36 - **Schema Type:** 37 - [`place.stream.multistream.defs#targetView`](/lex-reference/place-stream-multistream-defs#targetview) 38 39 **Possible Errors:** 40
··· 33 - **Encoding:** `application/json` 34 - **Schema:** 35 36 + **Schema Type:** [`place.stream.multistream.defs#targetView`](/lex-reference/place-stream-multistream-defs#targetview) 37 38 **Possible Errors:** 39
+1 -2
js/docs/src/content/docs/lex-reference/multistream/place-stream-multistream-puttarget.md
··· 34 - **Encoding:** `application/json` 35 - **Schema:** 36 37 - **Schema Type:** 38 - [`place.stream.multistream.defs#targetView`](/lex-reference/place-stream-multistream-defs#targetview) 39 40 **Possible Errors:** 41
··· 34 - **Encoding:** `application/json` 35 - **Schema:** 36 37 + **Schema Type:** [`place.stream.multistream.defs#targetView`](/lex-reference/place-stream-multistream-defs#targetview) 38 39 **Possible Errors:** 40
+9
js/docs/src/content/docs/reference.mdx
···
··· 1 + --- 2 + title: API Reference 3 + description: Our XRPC and OpenAPI Reference documentation 4 + template: doc 5 + --- 6 + 7 + import { Card, CardGrid } from "@astrojs/starlight/components"; 8 + 9 + Here contains our XRPC and OpenAPI Reference documentation.
+1 -1
lerna.json
··· 1 { 2 "$schema": "node_modules/lerna/schemas/lerna-schema.json", 3 - "version": "0.9.8", 4 "npmClient": "pnpm" 5 }
··· 1 { 2 "$schema": "node_modules/lerna/schemas/lerna-schema.json", 3 + "version": "0.9.9", 4 "npmClient": "pnpm" 5 }
+46
lexicons/place/stream/badge/defs.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.badge.defs", 4 + "defs": { 5 + "badgeView": { 6 + "type": "object", 7 + "required": ["badgeType", "issuer", "recipient"], 8 + "description": "View of a badge record, with fields resolved for display. If the DID in issuer is not the current streamplace node, the signature field shall be required.", 9 + "properties": { 10 + "badgeType": { 11 + "type": "string", 12 + "knownValues": [ 13 + "place.stream.badge.defs#mod", 14 + "place.stream.badge.defs#streamer" 15 + ] 16 + }, 17 + "issuer": { 18 + "type": "string", 19 + "format": "did", 20 + "description": "DID of the badge issuer." 21 + }, 22 + "recipient": { 23 + "type": "string", 24 + "format": "did", 25 + "description": "DID of the badge recipient." 26 + }, 27 + "signature": { 28 + "type": "string", 29 + "description": "TODO: Cryptographic signature of the badge (of a place.stream.key)." 30 + } 31 + } 32 + }, 33 + "mod": { 34 + "type": "token", 35 + "description": "This user is a moderator. Displayed with a sword icon." 36 + }, 37 + "streamer": { 38 + "type": "token", 39 + "description": "This user is the streamer. Displayed with a star icon." 40 + }, 41 + "vip": { 42 + "type": "token", 43 + "description": "This user is a very important person." 44 + } 45 + } 46 + }
+44
lexicons/place/stream/badge/display.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.badge.display", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Record issuing a badge to a user.", 8 + "record": { 9 + "type": "object", 10 + "required": ["badges"], 11 + "properties": { 12 + "badges": { 13 + "type": "array", 14 + "description": "Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable.", 15 + "maxLength": 3, 16 + "items": { 17 + "type": "ref", 18 + "ref": "#badgeSelection" 19 + } 20 + } 21 + } 22 + } 23 + }, 24 + "badgeSelection": { 25 + "type": "object", 26 + "description": "A badge selected for display. May be a full badgeView from the server, or a token representing a badge type that the client can look up for display info.", 27 + "required": ["badgeType"], 28 + "properties": { 29 + "badgeType": { 30 + "type": "string", 31 + "knownValues": [ 32 + "place.stream.badge.defs#mod", 33 + "place.stream.badge.defs#vip" 34 + ] 35 + }, 36 + "issuance": { 37 + "type": "string", 38 + "format": "at-uri", 39 + "description": "URI of the badge issuance record (place.stream.badge.issuance) that represents this badge. Required if badgeType is not recognized." 40 + } 41 + } 42 + } 43 + } 44 + }
+30
lexicons/place/stream/badge/issuance.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.badge.issuance", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "description": "Record issuing a badge to a user.", 9 + "record": { 10 + "type": "object", 11 + "required": ["badgeType", "recipient", "signature"], 12 + "properties": { 13 + "badgeType": { 14 + "type": "string", 15 + "knownValues": ["place.stream.badge.defs#vip"] 16 + }, 17 + "recipient": { 18 + "type": "string", 19 + "format": "did", 20 + "description": "DID of the badge recipient." 21 + }, 22 + "signature": { 23 + "type": "string", 24 + "description": "TODO: Cryptographic signature of the badge (of a place.stream.key)." 25 + } 26 + } 27 + } 28 + } 29 + } 30 + }
+9
lexicons/place/stream/chat/defs.json
··· 25 "deleted": { 26 "type": "boolean", 27 "description": "If true, this message has been deleted or labeled and should be cleared from the cache" 28 } 29 } 30 }
··· 25 "deleted": { 26 "type": "boolean", 27 "description": "If true, this message has been deleted or labeled and should be cleared from the cache" 28 + }, 29 + "badges": { 30 + "type": "array", 31 + "description": "Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable. Tokens are looked up in badges.json for display info.", 32 + "maxLength": 3, 33 + "items": { 34 + "type": "ref", 35 + "ref": "place.stream.badge.defs#badgeView" 36 + } 37 } 38 } 39 }
+1 -1
pkg/api/api.go
··· 272 if err != nil { 273 return nil, err 274 } 275 - linker, err := linking.NewLinker(ctx, bs) 276 if err != nil { 277 return nil, err 278 }
··· 272 if err != nil { 273 return nil, err 274 } 275 + linker, err := linking.NewLinker(ctx, bs, a.StatefulDB, a.CLI) 276 if err != nil { 277 return nil, err 278 }
+9
pkg/api/websocket.go
··· 3 import ( 4 "context" 5 "encoding/json" 6 "net" 7 "net/http" 8 "time" ··· 12 "github.com/gorilla/websocket" 13 "github.com/julienschmidt/httprouter" 14 15 apierrors "stream.place/streamplace/pkg/errors" 16 "stream.place/streamplace/pkg/log" 17 "stream.place/streamplace/pkg/renditions" ··· 237 log.Error(ctx, "could not get chat messages", "error", err) 238 return 239 } 240 for _, message := range messages { 241 initialBurst <- message 242 } 243 }()
··· 3 import ( 4 "context" 5 "encoding/json" 6 + "fmt" 7 "net" 8 "net/http" 9 "time" ··· 13 "github.com/gorilla/websocket" 14 "github.com/julienschmidt/httprouter" 15 16 + "stream.place/streamplace/pkg/atproto" 17 apierrors "stream.place/streamplace/pkg/errors" 18 "stream.place/streamplace/pkg/log" 19 "stream.place/streamplace/pkg/renditions" ··· 239 log.Error(ctx, "could not get chat messages", "error", err) 240 return 241 } 242 + 243 + // Add mod badges to messages 244 + issuerDID := fmt.Sprintf("did:web:%s", a.CLI.BroadcasterHost) 245 for _, message := range messages { 246 + err := atproto.AddModBadgeIfApplicable(ctx, message, repoDID, issuerDID, a.Model) 247 + if err != nil { 248 + log.Error(ctx, "failed to add mod badge to message", "error", err) 249 + } 250 initialBurst <- message 251 } 252 }()
+30 -7
pkg/aqtime/aqtime.go
··· 7 "time" 8 ) 9 10 var RE *regexp.Regexp 11 - var Pattern string = `^(\d\d\d\d)-(\d\d)-(\d\d)T(\d\d)(?:[:-])(\d\d)(?:[:-])(\d\d)(?:[.-])(\d\d\d)Z$` 12 - 13 - type AQTime string 14 15 func init() { 16 RE = regexp.MustCompile(fmt.Sprintf(`^%s$`, Pattern)) 17 } 18 19 var fstr = "2006-01-02T15:04:05.000Z" 20 21 // return a consistently formatted timestamp 22 func FromMillis(ms int64) AQTime { ··· 29 } 30 31 func FromString(str string) (AQTime, error) { 32 - bits := RE.FindStringSubmatch(str) 33 - if bits == nil { 34 - return "", fmt.Errorf("bad time format, expected=%s got=%s", fstr, str) 35 } 36 - return AQTime(str), nil 37 } 38 39 func FromTime(t time.Time) AQTime {
··· 7 "time" 8 ) 9 10 + // RE matches the canonical internal format: 2006-01-02T15:04:05.000Z 11 + // It also accepts the file-safe variant with dashes/dots swapped, for backward compat. 12 var RE *regexp.Regexp 13 + var Pattern string = `(\d\d\d\d)-(\d\d)-(\d\d)T(\d\d)(?:[:-])(\d\d)(?:[:-])(\d\d)(?:[.-])(\d\d\d)Z` 14 15 func init() { 16 RE = regexp.MustCompile(fmt.Sprintf(`^%s$`, Pattern)) 17 } 18 19 var fstr = "2006-01-02T15:04:05.000Z" 20 + 21 + type AQTime string 22 23 // return a consistently formatted timestamp 24 func FromMillis(ms int64) AQTime { ··· 31 } 32 33 func FromString(str string) (AQTime, error) { 34 + // Reject -00:00 (valid RFC 3339 but disallowed by ATProto) 35 + if strings.HasSuffix(str, "-00:00") { 36 + return "", fmt.Errorf("bad time format, -00:00 timezone offset is not allowed, got=%s", str) 37 + } 38 + 39 + t, err := time.Parse(time.RFC3339Nano, str) 40 + if err != nil { 41 + // Fall back to file-safe variant (e.g. 2024-09-13T18-10-17-090Z) 42 + if bits := RE.FindStringSubmatch(str); bits != nil { 43 + if bits[2] < "01" || bits[2] > "12" || bits[3] < "01" || bits[3] > "31" || 44 + bits[4] > "23" || bits[5] > "59" || bits[6] > "60" { 45 + return "", fmt.Errorf("bad time format, invalid date/time values in %s", str) 46 + } 47 + return AQTime(str), nil 48 + } 49 + return "", fmt.Errorf("bad time format: %w", err) 50 } 51 + 52 + // Reject if UTC normalization results in a negative year 53 + utc := t.UTC() 54 + if utc.Year() < 0 { 55 + return "", fmt.Errorf("bad time format, datetime normalizes to negative year: %s", str) 56 + } 57 + 58 + // Normalize to canonical UTC millisecond format 59 + return AQTime(utc.Format(fstr)), nil 60 } 61 62 func FromTime(t time.Time) AQTime {
+63 -2
pkg/aqtime/aqtime_test.go
··· 35 } 36 } 37 38 func TestBadCases(t *testing.T) { 39 for _, str := range []string{ 40 "prefix2024-09-13T18:10:17.090Z", 41 "2024-09-13T18-10-17-090Zsuffix", 42 "2024-09-13T18-10-17-090ZZZZ", 43 "2024-09-13T18-10-17*090ZZZZ", 44 } { 45 - _, err := FromString(str) 46 - require.Error(t, err) 47 } 48 }
··· 35 } 36 } 37 38 + // Valid ATProto datetime examples from the spec 39 + // https://atproto.com/specs/lexicon#datetime 40 + func TestATProtoValidCases(t *testing.T) { 41 + tests := []struct { 42 + input string 43 + wantMs string // expected millisecond portion after normalization 44 + wantHr string // expected hour after UTC normalization 45 + wantMin string 46 + }{ 47 + {"1985-04-12T23:20:50.123Z", "123", "23", "20"}, 48 + {"1985-04-12T23:20:50.123456Z", "123", "23", "20"}, 49 + {"1985-04-12T23:20:50.120Z", "120", "23", "20"}, 50 + {"1985-04-12T23:20:50.120000Z", "120", "23", "20"}, 51 + {"0001-01-01T00:00:00.000Z", "000", "00", "00"}, 52 + {"0000-01-01T00:00:00.000Z", "000", "00", "00"}, 53 + {"1985-04-12T23:20:50.12345678912345Z", "123", "23", "20"}, 54 + {"1985-04-12T23:20:50Z", "000", "23", "20"}, 55 + {"1985-04-12T23:20:50.0Z", "000", "23", "20"}, 56 + {"1985-04-12T23:20:50.123+00:00", "123", "23", "20"}, 57 + {"1985-04-12T23:20:50.123-07:00", "123", "06", "20"}, // 23+7=30 -> next day 06:20 58 + } 59 + for _, tt := range tests { 60 + t.Run(tt.input, func(t *testing.T) { 61 + aqt, err := FromString(tt.input) 62 + require.NoError(t, err, "input: %s", tt.input) 63 + _, _, _, hr, min, _, ms := aqt.Parts() 64 + require.Equal(t, tt.wantMs, ms, "millis mismatch for %s", tt.input) 65 + require.Equal(t, tt.wantHr, hr, "hour mismatch for %s", tt.input) 66 + require.Equal(t, tt.wantMin, min, "minute mismatch for %s", tt.input) 67 + }) 68 + } 69 + } 70 + 71 func TestBadCases(t *testing.T) { 72 for _, str := range []string{ 73 + // existing cases 74 "prefix2024-09-13T18:10:17.090Z", 75 "2024-09-13T18-10-17-090Zsuffix", 76 "2024-09-13T18-10-17-090ZZZZ", 77 "2024-09-13T18-10-17*090ZZZZ", 78 + // ATProto spec invalid examples 79 + "1985-04-12", 80 + "1985-04-12T23:20Z", 81 + "1985-04-12T23:20:5Z", 82 + "1985-04-12T23:20:50.123", 83 + "+001985-04-12T23:20:50.123Z", 84 + "23:20:50.123Z", 85 + "-1985-04-12T23:20:50.123Z", 86 + "1985-4-12T23:20:50.123Z", 87 + "01985-04-12T23:20:50.123Z", 88 + "1985-04-12T23:20:50.123+00", 89 + "1985-04-12T23:20:50.123+0000", 90 + // ISO-8601 strict capitalization 91 + "1985-04-12t23:20:50.123Z", 92 + "1985-04-12T23:20:50.123z", 93 + // RFC-3339, but not ISO-8601 94 + "1985-04-12T23:20:50.123-00:00", 95 + "1985-04-12 23:20:50.123Z", 96 + // timezone is required 97 + "1985-04-12T23:20:50.123", 98 + // syntax looks ok, but datetime is not valid 99 + "1985-04-12T23:99:50.123Z", 100 + "1985-00-12T23:20:50.123Z", 101 + // ISO-8601, but normalizes to a negative time 102 + "0000-01-01T00:00:00+01:00", 103 } { 104 + t.Run(str, func(t *testing.T) { 105 + _, err := FromString(str) 106 + require.Error(t, err, "expected error for: %s", str) 107 + }) 108 } 109 }
+61
pkg/atproto/badges.go
···
··· 1 + package atproto 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + "stream.place/streamplace/pkg/constants" 8 + "stream.place/streamplace/pkg/log" 9 + "stream.place/streamplace/pkg/model" 10 + "stream.place/streamplace/pkg/streamplace" 11 + ) 12 + 13 + // AddModBadgeIfApplicable checks if a message author has mod permissions for the streamer 14 + // and adds a mod or streamer badge as the first badge (server-controlled). 15 + // - If the author is the streamer, adds a "streamer" badge 16 + // - If the author has moderation permissions, adds a "mod" badge 17 + func AddModBadgeIfApplicable(ctx context.Context, message *streamplace.ChatDefs_MessageView, streamerDID string, issuerDID string, m model.Model) error { 18 + if message == nil { 19 + return fmt.Errorf("message is nil") 20 + } 21 + 22 + authorDID := message.Author.Did 23 + 24 + var badge *streamplace.BadgeDefs_BadgeView 25 + 26 + // Check if author is the streamer 27 + if authorDID == streamerDID { 28 + badge = &streamplace.BadgeDefs_BadgeView{ 29 + BadgeType: constants.BadgeTypeStreamer, 30 + Issuer: issuerDID, 31 + Recipient: authorDID, 32 + } 33 + } else { 34 + // Check if author has any moderation permissions for the streamer 35 + delegations, err := m.GetModerationDelegations(ctx, streamerDID, authorDID) 36 + if err != nil { 37 + log.Error(ctx, "failed to get moderation delegations", "err", err, "authorDID", authorDID, "streamerDID", streamerDID) 38 + return err 39 + } 40 + 41 + // If the author has any delegations (meaning they're a moderator), add a mod badge 42 + if len(delegations) > 0 { 43 + badge = &streamplace.BadgeDefs_BadgeView{ 44 + BadgeType: constants.BadgeTypeMod, 45 + Issuer: issuerDID, 46 + Recipient: authorDID, 47 + } 48 + } 49 + } 50 + 51 + // Prepend the badge if one was created (server-controlled badge is first) 52 + if badge != nil { 53 + if message.Badges == nil { 54 + message.Badges = []*streamplace.BadgeDefs_BadgeView{badge} 55 + } else { 56 + message.Badges = append([]*streamplace.BadgeDefs_BadgeView{badge}, message.Badges...) 57 + } 58 + } 59 + 60 + return nil 61 + }
+100
pkg/atproto/badges_test.go
···
··· 1 + package atproto 2 + 3 + import ( 4 + "context" 5 + "testing" 6 + "time" 7 + 8 + bsky "github.com/bluesky-social/indigo/api/bsky" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/bluesky-social/indigo/util" 11 + "github.com/stretchr/testify/require" 12 + "stream.place/streamplace/pkg/model" 13 + "stream.place/streamplace/pkg/streamplace" 14 + ) 15 + 16 + func TestAddModBadge(t *testing.T) { 17 + ctx := context.Background() 18 + 19 + mod, err := model.MakeDB(":memory:") 20 + require.NoError(t, err) 21 + 22 + streamerDID := "did:plc:streamer" 23 + moderatorDID := "did:plc:moderator" 24 + issuerDID := "did:web:example.com" 25 + 26 + // Create a chat message 27 + message := &streamplace.ChatDefs_MessageView{ 28 + LexiconTypeID: "place.stream.chat.defs#messageView", 29 + Uri: "at://test/place.stream.chat.message/123", 30 + Cid: "test-cid", 31 + Author: &bsky.ActorDefs_ProfileViewBasic{ 32 + Did: moderatorDID, 33 + Handle: "moderator.test", 34 + }, 35 + IndexedAt: "2024-01-01T00:00:00Z", 36 + } 37 + 38 + t.Run("no badge when user is not a moderator", func(t *testing.T) { 39 + msg := *message // copy 40 + err := AddModBadgeIfApplicable(ctx, &msg, streamerDID, issuerDID, mod) 41 + require.NoError(t, err) 42 + require.Nil(t, msg.Badges, "should not have badges when user is not a moderator") 43 + }) 44 + 45 + t.Run("adds streamer badge when user is the streamer", func(t *testing.T) { 46 + msg := *message // copy 47 + msg.Author = &bsky.ActorDefs_ProfileViewBasic{ 48 + Did: streamerDID, 49 + Handle: "streamer.test", 50 + } 51 + err := AddModBadgeIfApplicable(ctx, &msg, streamerDID, issuerDID, mod) 52 + require.NoError(t, err) 53 + require.Len(t, msg.Badges, 1, "should have 1 badge when user is the streamer") 54 + require.Equal(t, "place.stream.badge.defs#streamer", msg.Badges[0].BadgeType) 55 + require.Equal(t, issuerDID, msg.Badges[0].Issuer) 56 + require.Equal(t, streamerDID, msg.Badges[0].Recipient) 57 + }) 58 + 59 + t.Run("adds mod badge when user has moderation permissions", func(t *testing.T) { 60 + // Grant moderation permissions to the moderator 61 + perm := &streamplace.ModerationPermission{ 62 + LexiconTypeID: "place.stream.moderation.permission", 63 + Moderator: moderatorDID, 64 + Permissions: []string{"ban", "hide"}, 65 + CreatedAt: time.Now().Format(util.ISO8601), 66 + } 67 + aturi, err := syntax.ParseATURI("at://" + streamerDID + "/place.stream.moderation.permission/test123") 68 + require.NoError(t, err) 69 + 70 + // Sync the permission to the model 71 + err = mod.CreateModerationDelegation(ctx, perm, aturi) 72 + require.NoError(t, err) 73 + 74 + msg := *message // copy 75 + err = AddModBadgeIfApplicable(ctx, &msg, streamerDID, issuerDID, mod) 76 + require.NoError(t, err) 77 + require.Len(t, msg.Badges, 1, "should have 1 badge when user is a moderator") 78 + require.Equal(t, "place.stream.badges.badge#mod", msg.Badges[0].BadgeType) 79 + require.Equal(t, issuerDID, msg.Badges[0].Issuer) 80 + require.Equal(t, moderatorDID, msg.Badges[0].Recipient) 81 + }) 82 + 83 + t.Run("prepends mod badge to existing badges", func(t *testing.T) { 84 + // Create message with existing user-settable badge 85 + msg := *message // copy 86 + msg.Badges = []*streamplace.BadgeDefs_BadgeView{ 87 + { 88 + BadgeType: "place.stream.badges.badge#vip", 89 + Issuer: "did:web:other.com", 90 + Recipient: moderatorDID, 91 + }, 92 + } 93 + 94 + err = AddModBadgeIfApplicable(ctx, &msg, streamerDID, issuerDID, mod) 95 + require.NoError(t, err) 96 + require.Len(t, msg.Badges, 2, "should have 2 badges") 97 + require.Equal(t, "place.stream.badges.badge#mod", msg.Badges[0].BadgeType, "mod badge should be first") 98 + require.Equal(t, "place.stream.badges.badge#vip", msg.Badges[1].BadgeType, "vip badge should be second") 99 + }) 100 + }
+8
pkg/atproto/sync.go
··· 150 log.Error(ctx, "failed to convert chat message to streamplace message view", "err", err) 151 return nil 152 } 153 go atsync.Bus.Publish(rec.Streamer, scm) 154 155 if !isUpdate && !isFirstSync {
··· 150 log.Error(ctx, "failed to convert chat message to streamplace message view", "err", err) 151 return nil 152 } 153 + 154 + // Add mod badge if the author is a moderator 155 + issuerDID := fmt.Sprintf("did:web:%s", atsync.CLI.BroadcasterHost) 156 + err = AddModBadgeIfApplicable(ctx, scm, rec.Streamer, issuerDID, atsync.Model) 157 + if err != nil { 158 + log.Error(ctx, "failed to add mod badge", "err", err) 159 + } 160 + 161 go atsync.Bus.Publish(rec.Streamer, scm) 162 163 if !isUpdate && !isFirstSync {
+1
pkg/cmd/streamplace.go
··· 385 DownstreamJWK: cli.AccessJWK, 386 ClientMetadata: clientMetadata, 387 Public: cli.PublicOAuth, 388 }) 389 d := director.NewDirector(mm, mod, &cli, b, op, state, replicator, ldb) 390 a, err := api.MakeStreamplaceAPI(&cli, mod, state, noter, mm, ms, b, atsync, d, op, ldb)
··· 385 DownstreamJWK: cli.AccessJWK, 386 ClientMetadata: clientMetadata, 387 Public: cli.PublicOAuth, 388 + HTTPClient: &aqhttp.Client, 389 }) 390 d := director.NewDirector(mm, mod, &cli, b, op, state, replicator, ldb) 391 a, err := api.MakeStreamplaceAPI(&cli, mod, state, noter, mm, ms, b, atsync, d, op, ldb)
+6
pkg/constants/constants.go
··· 15 var PLACE_STREAM_DEFAULT_METADATA = "place.stream.metadata.configuration" //nolint:all 16 var PLACE_STREAM_LIVE_RECOMMENDATIONS = "place.stream.live.recommendations" //nolint:all 17 18 const DID_KEY_PREFIX = "did:key" //nolint:all 19 const ADDRESS_KEY_PREFIX = "0x" //nolint:all 20
··· 15 var PLACE_STREAM_DEFAULT_METADATA = "place.stream.metadata.configuration" //nolint:all 16 var PLACE_STREAM_LIVE_RECOMMENDATIONS = "place.stream.live.recommendations" //nolint:all 17 18 + // Streamplace badge types 19 + const ( 20 + BadgeTypeMod = "place.stream.badge.defs#mod" 21 + BadgeTypeStreamer = "place.stream.badge.defs#streamer" 22 + ) 23 + 24 const DID_KEY_PREFIX = "did:key" //nolint:all 25 const ADDRESS_KEY_PREFIX = "0x" //nolint:all 26
+3
pkg/gen/gen.go
··· 36 streamplace.ModerationPermission{}, 37 streamplace.LiveTeleport{}, 38 streamplace.LiveRecommendations{}, 39 ); err != nil { 40 panic(err) 41 }
··· 36 streamplace.ModerationPermission{}, 37 streamplace.LiveTeleport{}, 38 streamplace.LiveRecommendations{}, 39 + streamplace.BadgeIssuance{}, 40 + streamplace.BadgeDisplay{}, 41 + streamplace.BadgeDisplay_BadgeSelection{}, 42 ); err != nil { 43 panic(err) 44 }
+1 -1
pkg/integrations/discord/send-livestream.go
··· 67 log.Warn(ctx, "failed to parse URL", "err", err) 68 } else { 69 suffix = fmt.Sprintf(" on %s!", u.Host) 70 - payload.Embeds[0].URL = fmt.Sprintf("%s/%s", *ls.Url, lsv.Author.Handle) 71 } 72 } 73
··· 67 log.Warn(ctx, "failed to parse URL", "err", err) 68 } else { 69 suffix = fmt.Sprintf(" on %s!", u.Host) 70 + payload.Embeds[0].URL = *ls.Url 71 } 72 } 73
+139 -11
pkg/linking/linking.go
··· 3 import ( 4 "bytes" 5 "context" 6 "errors" 7 "fmt" 8 "net/url" 9 10 "golang.org/x/net/html" 11 "stream.place/streamplace/pkg/streamplace" 12 ) 13 14 type Linker struct { 15 BaseHTML []byte 16 } 17 18 - func NewLinker(ctx context.Context, baseHTML []byte) (*Linker, error) { 19 _, err := html.Parse(bytes.NewReader(baseHTML)) 20 if err != nil { 21 return nil, err 22 } 23 24 - return &Linker{BaseHTML: baseHTML}, nil 25 } 26 27 type PageConfig struct { 28 Title string 29 Metas []MetaTag 30 SentryDSN string 31 } 32 33 // Define all meta tags in a structured way ··· 37 Content string 38 } 39 40 func (l *Linker) GenerateStreamerCard(ctx context.Context, u *url.URL, lsv *streamplace.Livestream_LivestreamView, sentryDSN string) ([]byte, error) { 41 if u == nil { 42 return nil, errors.New("url is nil") ··· 49 return nil, errors.New("livestream view is not a livestream") 50 } 51 52 - titleStr := fmt.Sprintf("@%s's livestream on %s", lsv.Author.Handle, u.Host) 53 outURL := u.String() 54 - 55 - pageTitle := fmt.Sprintf("@%s | %s", lsv.Author.Handle, u.Host) 56 57 thumbURL, _ := url.Parse(u.String()) 58 thumbURL.Path = "/xrpc/place.stream.live.getProfileCard" ··· 66 // Facebook Meta Tags 67 {Type: "property", Key: "og:url", Content: u.String()}, 68 {Type: "property", Key: "og:type", Content: "website"}, 69 - {Type: "property", Key: "og:title", Content: titleStr}, 70 {Type: "property", Key: "og:description", Content: ls.Title}, 71 {Type: "property", Key: "og:image", Content: thumbURL.String()}, 72 ··· 74 {Type: "name", Key: "twitter:card", Content: "summary_large_image"}, 75 {Type: "property", Key: "twitter:domain", Content: u.Host}, 76 {Type: "property", Key: "twitter:url", Content: outURL}, 77 - {Type: "name", Key: "twitter:title", Content: titleStr}, 78 {Type: "name", Key: "twitter:description", Content: ls.Title}, 79 {Type: "name", Key: "twitter:image", Content: thumbURL.String()}, 80 } 81 82 return l.GenerateHTML(ctx, &PageConfig{ 83 - Title: pageTitle, 84 Metas: metaTags, 85 SentryDSN: sentryDSN, 86 }) ··· 103 {Type: "property", Key: "og:url", Content: u.String()}, 104 {Type: "property", Key: "og:type", Content: "website"}, 105 {Type: "property", Key: "og:title", Content: "Stream.place"}, 106 - {Type: "property", Key: "og:description", Content: "Stream.place is open-source livestreaming on the AT Protocol."}, 107 {Type: "property", Key: "og:image", Content: thumbURL.String()}, 108 109 // Twitter Meta Tags ··· 111 {Type: "property", Key: "twitter:domain", Content: u.Host}, 112 {Type: "property", Key: "twitter:url", Content: u.String()}, 113 {Type: "name", Key: "twitter:title", Content: "Stream.place"}, 114 - {Type: "name", Key: "twitter:description", Content: "Stream.place is open-source livestreaming on the AT Protocol."}, 115 {Type: "name", Key: "twitter:image", Content: thumbURL.String()}, 116 } 117 118 return l.GenerateHTML(ctx, &PageConfig{ 119 - Title: "Stream.place", 120 Metas: metaTags, 121 SentryDSN: sentryDSN, 122 })
··· 3 import ( 4 "bytes" 5 "context" 6 + "encoding/json" 7 "errors" 8 "fmt" 9 + "log" 10 "net/url" 11 12 "golang.org/x/net/html" 13 + "stream.place/streamplace/pkg/config" 14 + "stream.place/streamplace/pkg/statedb" 15 "stream.place/streamplace/pkg/streamplace" 16 ) 17 18 type Linker struct { 19 BaseHTML []byte 20 + sdb *statedb.StatefulDB 21 + cli *config.CLI 22 } 23 24 + func NewLinker(ctx context.Context, baseHTML []byte, sdb *statedb.StatefulDB, cli *config.CLI) (*Linker, error) { 25 _, err := html.Parse(bytes.NewReader(baseHTML)) 26 if err != nil { 27 return nil, err 28 } 29 30 + return &Linker{BaseHTML: baseHTML, sdb: sdb, cli: cli}, nil 31 } 32 33 type PageConfig struct { 34 Title string 35 Metas []MetaTag 36 SentryDSN string 37 + Branding []string 38 } 39 40 // Define all meta tags in a structured way ··· 44 Content string 45 } 46 47 + var BrandingAssetList = [...]string{ 48 + "siteTitle", 49 + "siteDescription", 50 + "primaryColor", 51 + "accentColor", 52 + "defaultStreamer", 53 + "mainLogo", 54 + "favicon", 55 + "sidebarBg", 56 + "legalLinks", 57 + } 58 + 59 + // fetch branding assets for a given broadcaster DID 60 + func (l *Linker) getBrandingAssets(broadcasterDid string) ([]streamplace.BrandingGetBranding_BrandingAsset, error) { 61 + ret := make([]streamplace.BrandingGetBranding_BrandingAsset, 0) 62 + for _, asset := range BrandingAssetList { 63 + blob, err := l.sdb.GetBrandingBlob(broadcasterDid, asset) 64 + if err != nil { 65 + // this can probably include a 'record not found' error, in which case we skip 66 + log.Printf("error fetching branding asset %s for broadcaster %s: %v", asset, broadcasterDid, err) 67 + continue 68 + } 69 + asset := streamplace.BrandingGetBranding_BrandingAsset{ 70 + Key: blob.Key, 71 + MimeType: blob.MimeType, 72 + } 73 + 74 + if blob.Width != nil { 75 + w := int64(*blob.Width) 76 + asset.Width = &w 77 + } 78 + if blob.Height != nil { 79 + h := int64(*blob.Height) 80 + asset.Height = &h 81 + } 82 + 83 + // process based on mime type 84 + if blob.MimeType == "text/plain" { 85 + str := string(blob.Data) 86 + asset.Data = &str 87 + } else { 88 + url := fmt.Sprintf("/xrpc/place.stream.branding.getBlob?key=%s&broadcaster=%s", blob.Key, broadcasterDid) 89 + asset.Url = &url 90 + } 91 + ret = append(ret, asset) 92 + } 93 + 94 + return ret, nil 95 + } 96 + 97 func (l *Linker) GenerateStreamerCard(ctx context.Context, u *url.URL, lsv *streamplace.Livestream_LivestreamView, sentryDSN string) ([]byte, error) { 98 if u == nil { 99 return nil, errors.New("url is nil") ··· 106 return nil, errors.New("livestream view is not a livestream") 107 } 108 109 + titleStr := fmt.Sprintf("@%s's livestream on ", lsv.Author.Handle) 110 outURL := u.String() 111 112 thumbURL, _ := url.Parse(u.String()) 113 thumbURL.Path = "/xrpc/place.stream.live.getProfileCard" ··· 121 // Facebook Meta Tags 122 {Type: "property", Key: "og:url", Content: u.String()}, 123 {Type: "property", Key: "og:type", Content: "website"}, 124 {Type: "property", Key: "og:description", Content: ls.Title}, 125 {Type: "property", Key: "og:image", Content: thumbURL.String()}, 126 ··· 128 {Type: "name", Key: "twitter:card", Content: "summary_large_image"}, 129 {Type: "property", Key: "twitter:domain", Content: u.Host}, 130 {Type: "property", Key: "twitter:url", Content: outURL}, 131 {Type: "name", Key: "twitter:description", Content: ls.Title}, 132 {Type: "name", Key: "twitter:image", Content: thumbURL.String()}, 133 } 134 + brandingTitle := "streamplace node" 135 + if l.sdb != nil && l.cli != nil { 136 + branding, err := l.getBrandingAssets("did:web:" + l.cli.BroadcasterHost) 137 + if err == nil { 138 + for i := range branding { 139 + val := branding[i] 140 + if val.Key == "siteTitle" && val.Data != nil { 141 + brandingTitle = *val.Data 142 + } 143 + marshalledJson, err := json.Marshal(val) 144 + if err != nil { 145 + fmt.Printf("error marshalling branding asset %s: %v\n", val.Key, err) 146 + continue 147 + } 148 + metaTags = append(metaTags, MetaTag{ 149 + Type: "name", 150 + Key: "internal-brand:" + val.Key, 151 + Content: string(marshalledJson), 152 + }) 153 + } 154 + } else { 155 + // log but we should not block rendering 156 + fmt.Printf("error fetching branding assets: %v\n", err) 157 + } 158 + } 159 + 160 + // do twitter/og title after 161 + metaTags = append(metaTags, MetaTag{ 162 + Type: "property", 163 + Key: "og:title", 164 + Content: fmt.Sprintf("%s%s", titleStr, brandingTitle), 165 + }) 166 + metaTags = append(metaTags, MetaTag{ 167 + Type: "name", 168 + Key: "twitter:title", 169 + Content: fmt.Sprintf("%s%s", titleStr, brandingTitle), 170 + }) 171 172 return l.GenerateHTML(ctx, &PageConfig{ 173 + Title: fmt.Sprintf("%s%s", titleStr, brandingTitle), 174 Metas: metaTags, 175 SentryDSN: sentryDSN, 176 }) ··· 193 {Type: "property", Key: "og:url", Content: u.String()}, 194 {Type: "property", Key: "og:type", Content: "website"}, 195 {Type: "property", Key: "og:title", Content: "Stream.place"}, 196 + {Type: "property", Key: "og:description", Content: "Open-source livestreaming on the AT Protocol."}, 197 {Type: "property", Key: "og:image", Content: thumbURL.String()}, 198 199 // Twitter Meta Tags ··· 201 {Type: "property", Key: "twitter:domain", Content: u.Host}, 202 {Type: "property", Key: "twitter:url", Content: u.String()}, 203 {Type: "name", Key: "twitter:title", Content: "Stream.place"}, 204 + {Type: "name", Key: "twitter:description", Content: "Open-source livestreaming on the AT Protocol."}, 205 {Type: "name", Key: "twitter:image", Content: thumbURL.String()}, 206 } 207 208 + brandingTitle := "streamplace node" 209 + if l.sdb != nil && l.cli != nil { 210 + branding, err := l.getBrandingAssets("did:web:" + l.cli.BroadcasterHost) 211 + if err == nil { 212 + for i := range branding { 213 + val := branding[i] 214 + if val.Key == "siteTitle" && val.Data != nil { 215 + brandingTitle = *val.Data 216 + } 217 + marshalledJson, err := json.Marshal(val) 218 + if err != nil { 219 + fmt.Printf("error marshalling branding asset %s: %v\n", val.Key, err) 220 + continue 221 + } 222 + metaTags = append(metaTags, MetaTag{ 223 + Type: "name", 224 + Key: "internal-brand:" + val.Key, 225 + Content: string(marshalledJson), 226 + }) 227 + } 228 + } else { 229 + // log but we should not block rendering 230 + fmt.Printf("error fetching branding assets: %v\n", err) 231 + } 232 + } 233 + 234 + // do twitter/og title after 235 + metaTags = append(metaTags, MetaTag{ 236 + Type: "property", 237 + Key: "og:title", 238 + Content: brandingTitle, 239 + }) 240 + metaTags = append(metaTags, MetaTag{ 241 + Type: "name", 242 + Key: "twitter:title", 243 + Content: brandingTitle, 244 + }) 245 + 246 return l.GenerateHTML(ctx, &PageConfig{ 247 + Title: brandingTitle, 248 Metas: metaTags, 249 SentryDSN: sentryDSN, 250 })
+2 -2
pkg/linking/linking_test.go
··· 29 30 func TestNewLinker(t *testing.T) { 31 index := IndexHTML(t) 32 - linker, err := NewLinker(context.Background(), index) 33 require.NoError(t, err) 34 require.NotNil(t, linker) 35 } 36 37 func TestGenerateLinkCard(t *testing.T) { 38 index := IndexHTML(t) 39 - linker, err := NewLinker(context.Background(), index) 40 require.NoError(t, err) 41 require.NotNil(t, linker) 42
··· 29 30 func TestNewLinker(t *testing.T) { 31 index := IndexHTML(t) 32 + linker, err := NewLinker(context.Background(), index, nil, nil) 33 require.NoError(t, err) 34 require.NotNil(t, linker) 35 } 36 37 func TestGenerateLinkCard(t *testing.T) { 38 index := IndexHTML(t) 39 + linker, err := NewLinker(context.Background(), index, nil, nil) 40 require.NoError(t, err) 41 require.NotNil(t, linker) 42
+2 -1
pkg/spxrpc/com_atproto_identity.go
··· 5 6 comatprototypes "github.com/bluesky-social/indigo/api/atproto" 7 "github.com/streamplace/oatproxy/pkg/oatproxy" 8 ) 9 10 func (s *Server) handleComAtprotoIdentityResolveHandle(ctx context.Context, handle string) (*comatprototypes.IdentityResolveHandle_Output, error) { 11 - did, err := oatproxy.ResolveHandle(ctx, handle) 12 if err != nil { 13 return nil, err 14 }
··· 5 6 comatprototypes "github.com/bluesky-social/indigo/api/atproto" 7 "github.com/streamplace/oatproxy/pkg/oatproxy" 8 + "stream.place/streamplace/pkg/aqhttp" 9 ) 10 11 func (s *Server) handleComAtprotoIdentityResolveHandle(ctx context.Context, handle string) (*comatprototypes.IdentityResolveHandle_Output, error) { 12 + did, err := oatproxy.ResolveHandleWithClient(ctx, handle, &aqhttp.Client) 13 if err != nil { 14 return nil, err 15 }
+3 -2
pkg/spxrpc/com_atproto_repo.go
··· 15 "github.com/labstack/echo/v4" 16 "github.com/streamplace/oatproxy/pkg/oatproxy" 17 "go.opentelemetry.io/otel" 18 "stream.place/streamplace/pkg/atproto" 19 "stream.place/streamplace/pkg/log" 20 ) ··· 23 did := repo 24 var err error 25 if !strings.HasPrefix(repo, "did:") { 26 - did, err = oatproxy.ResolveHandle(ctx, repo) 27 if err != nil { 28 return "", "", "", fmt.Errorf("failed to resolve handle %q: %w", repo, err) 29 } 30 } 31 32 - service, handle, err := oatproxy.ResolveService(ctx, did) 33 if err != nil { 34 return "", "", "", fmt.Errorf("failed to resolve service for did %q: %w", did, err) 35 }
··· 15 "github.com/labstack/echo/v4" 16 "github.com/streamplace/oatproxy/pkg/oatproxy" 17 "go.opentelemetry.io/otel" 18 + "stream.place/streamplace/pkg/aqhttp" 19 "stream.place/streamplace/pkg/atproto" 20 "stream.place/streamplace/pkg/log" 21 ) ··· 24 did := repo 25 var err error 26 if !strings.HasPrefix(repo, "did:") { 27 + did, err = oatproxy.ResolveHandleWithClient(ctx, repo, &aqhttp.Client) 28 if err != nil { 29 return "", "", "", fmt.Errorf("failed to resolve handle %q: %w", repo, err) 30 } 31 } 32 33 + service, handle, err := oatproxy.ResolveServiceWithClient(ctx, did, &aqhttp.Client) 34 if err != nil { 35 return "", "", "", fmt.Errorf("failed to resolve service for did %q: %w", did, err) 36 }
+4 -4
pkg/spxrpc/place_stream_branding.go
··· 38 return s.cli.BroadcasterHost 39 } 40 41 - func (s *Server) getBrandingBlob(ctx context.Context, broadcasterID, key string) ([]byte, string, *int, *int, error) { 42 // cache miss - fetch from db 43 blob, err := s.statefulDB.GetBrandingBlob(broadcasterID, key) 44 if err == gorm.ErrRecordNotFound { ··· 61 // HandlePlaceStreamBrandingGetBlobDirect is the exported version for direct calls 62 func (s *Server) HandlePlaceStreamBrandingGetBlobDirect(ctx context.Context, broadcasterDID string, key string) (io.Reader, error) { 63 broadcasterID := s.getBroadcasterID(ctx, broadcasterDID) 64 - data, _, _, _, err := s.getBrandingBlob(ctx, broadcasterID, key) 65 if err != nil { 66 return nil, err 67 } ··· 94 // build output 95 assets := make([]*placestreamtypes.BrandingGetBranding_BrandingAsset, 0, len(allKeys)) 96 for key := range allKeys { 97 - data, mimeType, width, height, err := s.getBrandingBlob(ctx, broadcasterID, key) 98 if err != nil { 99 continue // skip if error 100 } ··· 238 239 broadcasterID := s.cli.BroadcasterHost 240 log.Log(ctx, "fetching favicon", "broadcasterID", broadcasterID) 241 - data, mimeType, _, _, err := s.getBrandingBlob(ctx, "did:web:"+broadcasterID, "favicon") 242 243 if err != nil || data == nil { 244 log.Log(ctx, "using fallback favicon", "err", err, "data_nil", data == nil)
··· 38 return s.cli.BroadcasterHost 39 } 40 41 + func (s *Server) GetBrandingBlob(ctx context.Context, broadcasterID, key string) ([]byte, string, *int, *int, error) { 42 // cache miss - fetch from db 43 blob, err := s.statefulDB.GetBrandingBlob(broadcasterID, key) 44 if err == gorm.ErrRecordNotFound { ··· 61 // HandlePlaceStreamBrandingGetBlobDirect is the exported version for direct calls 62 func (s *Server) HandlePlaceStreamBrandingGetBlobDirect(ctx context.Context, broadcasterDID string, key string) (io.Reader, error) { 63 broadcasterID := s.getBroadcasterID(ctx, broadcasterDID) 64 + data, _, _, _, err := s.GetBrandingBlob(ctx, broadcasterID, key) 65 if err != nil { 66 return nil, err 67 } ··· 94 // build output 95 assets := make([]*placestreamtypes.BrandingGetBranding_BrandingAsset, 0, len(allKeys)) 96 for key := range allKeys { 97 + data, mimeType, width, height, err := s.GetBrandingBlob(ctx, broadcasterID, key) 98 if err != nil { 99 continue // skip if error 100 } ··· 238 239 broadcasterID := s.cli.BroadcasterHost 240 log.Log(ctx, "fetching favicon", "broadcasterID", broadcasterID) 241 + data, mimeType, _, _, err := s.GetBrandingBlob(ctx, "did:web:"+broadcasterID, "favicon") 242 243 if err != nil || data == nil { 244 log.Log(ctx, "using fallback favicon", "err", err, "data_nil", data == nil)
+18
pkg/streamplace/badgedefs.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.badge.defs 4 + 5 + package streamplace 6 + 7 + // BadgeDefs_BadgeView is a "badgeView" in the place.stream.badge.defs schema. 8 + // 9 + // View of a badge record, with fields resolved for display. If the DID in issuer is not the current streamplace node, the signature field shall be required. 10 + type BadgeDefs_BadgeView struct { 11 + BadgeType string `json:"badgeType" cborgen:"badgeType"` 12 + // issuer: DID of the badge issuer. 13 + Issuer string `json:"issuer" cborgen:"issuer"` 14 + // recipient: DID of the badge recipient. 15 + Recipient string `json:"recipient" cborgen:"recipient"` 16 + // signature: TODO: Cryptographic signature of the badge (of a place.stream.key). 17 + Signature *string `json:"signature,omitempty" cborgen:"signature,omitempty"` 18 + }
+28
pkg/streamplace/badgedisplay.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.badge.display 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.badge.display", &BadgeDisplay{}) 13 + } 14 + 15 + type BadgeDisplay struct { 16 + LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.badge.display"` 17 + // badges: Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable. 18 + Badges []*BadgeDisplay_BadgeSelection `json:"badges" cborgen:"badges"` 19 + } 20 + 21 + // BadgeDisplay_BadgeSelection is a "badgeSelection" in the place.stream.badge.display schema. 22 + // 23 + // A badge selected for display. May be a full badgeView from the server, or a token representing a badge type that the client can look up for display info. 24 + type BadgeDisplay_BadgeSelection struct { 25 + BadgeType string `json:"badgeType" cborgen:"badgeType"` 26 + // issuance: URI of the badge issuance record (place.stream.badge.issuance) that represents this badge. Required if badgeType is not recognized. 27 + Issuance *string `json:"issuance,omitempty" cborgen:"issuance,omitempty"` 28 + }
+22
pkg/streamplace/badgeissuance.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.badge.issuance 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.badge.issuance", &BadgeIssuance{}) 13 + } 14 + 15 + type BadgeIssuance struct { 16 + LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.badge.issuance"` 17 + BadgeType string `json:"badgeType" cborgen:"badgeType"` 18 + // recipient: DID of the badge recipient. 19 + Recipient string `json:"recipient" cborgen:"recipient"` 20 + // signature: TODO: Cryptographic signature of the badge (of a place.stream.key). 21 + Signature string `json:"signature" cborgen:"signature"` 22 + }
+527
pkg/streamplace/cbor_gen.go
··· 5966 5967 return nil 5968 }
··· 5966 5967 return nil 5968 } 5969 + func (t *BadgeIssuance) MarshalCBOR(w io.Writer) error { 5970 + if t == nil { 5971 + _, err := w.Write(cbg.CborNull) 5972 + return err 5973 + } 5974 + 5975 + cw := cbg.NewCborWriter(w) 5976 + 5977 + if _, err := cw.Write([]byte{164}); err != nil { 5978 + return err 5979 + } 5980 + 5981 + // t.LexiconTypeID (string) (string) 5982 + if len("$type") > 1000000 { 5983 + return xerrors.Errorf("Value in field \"$type\" was too long") 5984 + } 5985 + 5986 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 5987 + return err 5988 + } 5989 + if _, err := cw.WriteString(string("$type")); err != nil { 5990 + return err 5991 + } 5992 + 5993 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("place.stream.badge.issuance"))); err != nil { 5994 + return err 5995 + } 5996 + if _, err := cw.WriteString(string("place.stream.badge.issuance")); err != nil { 5997 + return err 5998 + } 5999 + 6000 + // t.BadgeType (string) (string) 6001 + if len("badgeType") > 1000000 { 6002 + return xerrors.Errorf("Value in field \"badgeType\" was too long") 6003 + } 6004 + 6005 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("badgeType"))); err != nil { 6006 + return err 6007 + } 6008 + if _, err := cw.WriteString(string("badgeType")); err != nil { 6009 + return err 6010 + } 6011 + 6012 + if len(t.BadgeType) > 1000000 { 6013 + return xerrors.Errorf("Value in field t.BadgeType was too long") 6014 + } 6015 + 6016 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.BadgeType))); err != nil { 6017 + return err 6018 + } 6019 + if _, err := cw.WriteString(string(t.BadgeType)); err != nil { 6020 + return err 6021 + } 6022 + 6023 + // t.Recipient (string) (string) 6024 + if len("recipient") > 1000000 { 6025 + return xerrors.Errorf("Value in field \"recipient\" was too long") 6026 + } 6027 + 6028 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("recipient"))); err != nil { 6029 + return err 6030 + } 6031 + if _, err := cw.WriteString(string("recipient")); err != nil { 6032 + return err 6033 + } 6034 + 6035 + if len(t.Recipient) > 1000000 { 6036 + return xerrors.Errorf("Value in field t.Recipient was too long") 6037 + } 6038 + 6039 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Recipient))); err != nil { 6040 + return err 6041 + } 6042 + if _, err := cw.WriteString(string(t.Recipient)); err != nil { 6043 + return err 6044 + } 6045 + 6046 + // t.Signature (string) (string) 6047 + if len("signature") > 1000000 { 6048 + return xerrors.Errorf("Value in field \"signature\" was too long") 6049 + } 6050 + 6051 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("signature"))); err != nil { 6052 + return err 6053 + } 6054 + if _, err := cw.WriteString(string("signature")); err != nil { 6055 + return err 6056 + } 6057 + 6058 + if len(t.Signature) > 1000000 { 6059 + return xerrors.Errorf("Value in field t.Signature was too long") 6060 + } 6061 + 6062 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Signature))); err != nil { 6063 + return err 6064 + } 6065 + if _, err := cw.WriteString(string(t.Signature)); err != nil { 6066 + return err 6067 + } 6068 + return nil 6069 + } 6070 + 6071 + func (t *BadgeIssuance) UnmarshalCBOR(r io.Reader) (err error) { 6072 + *t = BadgeIssuance{} 6073 + 6074 + cr := cbg.NewCborReader(r) 6075 + 6076 + maj, extra, err := cr.ReadHeader() 6077 + if err != nil { 6078 + return err 6079 + } 6080 + defer func() { 6081 + if err == io.EOF { 6082 + err = io.ErrUnexpectedEOF 6083 + } 6084 + }() 6085 + 6086 + if maj != cbg.MajMap { 6087 + return fmt.Errorf("cbor input should be of type map") 6088 + } 6089 + 6090 + if extra > cbg.MaxLength { 6091 + return fmt.Errorf("BadgeIssuance: map struct too large (%d)", extra) 6092 + } 6093 + 6094 + n := extra 6095 + 6096 + nameBuf := make([]byte, 9) 6097 + for i := uint64(0); i < n; i++ { 6098 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 6099 + if err != nil { 6100 + return err 6101 + } 6102 + 6103 + if !ok { 6104 + // Field doesn't exist on this type, so ignore it 6105 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 6106 + return err 6107 + } 6108 + continue 6109 + } 6110 + 6111 + switch string(nameBuf[:nameLen]) { 6112 + // t.LexiconTypeID (string) (string) 6113 + case "$type": 6114 + 6115 + { 6116 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6117 + if err != nil { 6118 + return err 6119 + } 6120 + 6121 + t.LexiconTypeID = string(sval) 6122 + } 6123 + // t.BadgeType (string) (string) 6124 + case "badgeType": 6125 + 6126 + { 6127 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6128 + if err != nil { 6129 + return err 6130 + } 6131 + 6132 + t.BadgeType = string(sval) 6133 + } 6134 + // t.Recipient (string) (string) 6135 + case "recipient": 6136 + 6137 + { 6138 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6139 + if err != nil { 6140 + return err 6141 + } 6142 + 6143 + t.Recipient = string(sval) 6144 + } 6145 + // t.Signature (string) (string) 6146 + case "signature": 6147 + 6148 + { 6149 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6150 + if err != nil { 6151 + return err 6152 + } 6153 + 6154 + t.Signature = string(sval) 6155 + } 6156 + 6157 + default: 6158 + // Field doesn't exist on this type, so ignore it 6159 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 6160 + return err 6161 + } 6162 + } 6163 + } 6164 + 6165 + return nil 6166 + } 6167 + func (t *BadgeDisplay) MarshalCBOR(w io.Writer) error { 6168 + if t == nil { 6169 + _, err := w.Write(cbg.CborNull) 6170 + return err 6171 + } 6172 + 6173 + cw := cbg.NewCborWriter(w) 6174 + 6175 + if _, err := cw.Write([]byte{162}); err != nil { 6176 + return err 6177 + } 6178 + 6179 + // t.LexiconTypeID (string) (string) 6180 + if len("$type") > 1000000 { 6181 + return xerrors.Errorf("Value in field \"$type\" was too long") 6182 + } 6183 + 6184 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 6185 + return err 6186 + } 6187 + if _, err := cw.WriteString(string("$type")); err != nil { 6188 + return err 6189 + } 6190 + 6191 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("place.stream.badge.display"))); err != nil { 6192 + return err 6193 + } 6194 + if _, err := cw.WriteString(string("place.stream.badge.display")); err != nil { 6195 + return err 6196 + } 6197 + 6198 + // t.Badges ([]*streamplace.BadgeDisplay_BadgeSelection) (slice) 6199 + if len("badges") > 1000000 { 6200 + return xerrors.Errorf("Value in field \"badges\" was too long") 6201 + } 6202 + 6203 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("badges"))); err != nil { 6204 + return err 6205 + } 6206 + if _, err := cw.WriteString(string("badges")); err != nil { 6207 + return err 6208 + } 6209 + 6210 + if len(t.Badges) > 8192 { 6211 + return xerrors.Errorf("Slice value in field t.Badges was too long") 6212 + } 6213 + 6214 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Badges))); err != nil { 6215 + return err 6216 + } 6217 + for _, v := range t.Badges { 6218 + if err := v.MarshalCBOR(cw); err != nil { 6219 + return err 6220 + } 6221 + 6222 + } 6223 + return nil 6224 + } 6225 + 6226 + func (t *BadgeDisplay) UnmarshalCBOR(r io.Reader) (err error) { 6227 + *t = BadgeDisplay{} 6228 + 6229 + cr := cbg.NewCborReader(r) 6230 + 6231 + maj, extra, err := cr.ReadHeader() 6232 + if err != nil { 6233 + return err 6234 + } 6235 + defer func() { 6236 + if err == io.EOF { 6237 + err = io.ErrUnexpectedEOF 6238 + } 6239 + }() 6240 + 6241 + if maj != cbg.MajMap { 6242 + return fmt.Errorf("cbor input should be of type map") 6243 + } 6244 + 6245 + if extra > cbg.MaxLength { 6246 + return fmt.Errorf("BadgeDisplay: map struct too large (%d)", extra) 6247 + } 6248 + 6249 + n := extra 6250 + 6251 + nameBuf := make([]byte, 6) 6252 + for i := uint64(0); i < n; i++ { 6253 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 6254 + if err != nil { 6255 + return err 6256 + } 6257 + 6258 + if !ok { 6259 + // Field doesn't exist on this type, so ignore it 6260 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 6261 + return err 6262 + } 6263 + continue 6264 + } 6265 + 6266 + switch string(nameBuf[:nameLen]) { 6267 + // t.LexiconTypeID (string) (string) 6268 + case "$type": 6269 + 6270 + { 6271 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6272 + if err != nil { 6273 + return err 6274 + } 6275 + 6276 + t.LexiconTypeID = string(sval) 6277 + } 6278 + // t.Badges ([]*streamplace.BadgeDisplay_BadgeSelection) (slice) 6279 + case "badges": 6280 + 6281 + maj, extra, err = cr.ReadHeader() 6282 + if err != nil { 6283 + return err 6284 + } 6285 + 6286 + if extra > 8192 { 6287 + return fmt.Errorf("t.Badges: array too large (%d)", extra) 6288 + } 6289 + 6290 + if maj != cbg.MajArray { 6291 + return fmt.Errorf("expected cbor array") 6292 + } 6293 + 6294 + if extra > 0 { 6295 + t.Badges = make([]*BadgeDisplay_BadgeSelection, extra) 6296 + } 6297 + 6298 + for i := 0; i < int(extra); i++ { 6299 + { 6300 + var maj byte 6301 + var extra uint64 6302 + var err error 6303 + _ = maj 6304 + _ = extra 6305 + _ = err 6306 + 6307 + { 6308 + 6309 + b, err := cr.ReadByte() 6310 + if err != nil { 6311 + return err 6312 + } 6313 + if b != cbg.CborNull[0] { 6314 + if err := cr.UnreadByte(); err != nil { 6315 + return err 6316 + } 6317 + t.Badges[i] = new(BadgeDisplay_BadgeSelection) 6318 + if err := t.Badges[i].UnmarshalCBOR(cr); err != nil { 6319 + return xerrors.Errorf("unmarshaling t.Badges[i] pointer: %w", err) 6320 + } 6321 + } 6322 + 6323 + } 6324 + 6325 + } 6326 + } 6327 + 6328 + default: 6329 + // Field doesn't exist on this type, so ignore it 6330 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 6331 + return err 6332 + } 6333 + } 6334 + } 6335 + 6336 + return nil 6337 + } 6338 + func (t *BadgeDisplay_BadgeSelection) MarshalCBOR(w io.Writer) error { 6339 + if t == nil { 6340 + _, err := w.Write(cbg.CborNull) 6341 + return err 6342 + } 6343 + 6344 + cw := cbg.NewCborWriter(w) 6345 + fieldCount := 2 6346 + 6347 + if t.Issuance == nil { 6348 + fieldCount-- 6349 + } 6350 + 6351 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 6352 + return err 6353 + } 6354 + 6355 + // t.Issuance (string) (string) 6356 + if t.Issuance != nil { 6357 + 6358 + if len("issuance") > 1000000 { 6359 + return xerrors.Errorf("Value in field \"issuance\" was too long") 6360 + } 6361 + 6362 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("issuance"))); err != nil { 6363 + return err 6364 + } 6365 + if _, err := cw.WriteString(string("issuance")); err != nil { 6366 + return err 6367 + } 6368 + 6369 + if t.Issuance == nil { 6370 + if _, err := cw.Write(cbg.CborNull); err != nil { 6371 + return err 6372 + } 6373 + } else { 6374 + if len(*t.Issuance) > 1000000 { 6375 + return xerrors.Errorf("Value in field t.Issuance was too long") 6376 + } 6377 + 6378 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Issuance))); err != nil { 6379 + return err 6380 + } 6381 + if _, err := cw.WriteString(string(*t.Issuance)); err != nil { 6382 + return err 6383 + } 6384 + } 6385 + } 6386 + 6387 + // t.BadgeType (string) (string) 6388 + if len("badgeType") > 1000000 { 6389 + return xerrors.Errorf("Value in field \"badgeType\" was too long") 6390 + } 6391 + 6392 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("badgeType"))); err != nil { 6393 + return err 6394 + } 6395 + if _, err := cw.WriteString(string("badgeType")); err != nil { 6396 + return err 6397 + } 6398 + 6399 + if len(t.BadgeType) > 1000000 { 6400 + return xerrors.Errorf("Value in field t.BadgeType was too long") 6401 + } 6402 + 6403 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.BadgeType))); err != nil { 6404 + return err 6405 + } 6406 + if _, err := cw.WriteString(string(t.BadgeType)); err != nil { 6407 + return err 6408 + } 6409 + return nil 6410 + } 6411 + 6412 + func (t *BadgeDisplay_BadgeSelection) UnmarshalCBOR(r io.Reader) (err error) { 6413 + *t = BadgeDisplay_BadgeSelection{} 6414 + 6415 + cr := cbg.NewCborReader(r) 6416 + 6417 + maj, extra, err := cr.ReadHeader() 6418 + if err != nil { 6419 + return err 6420 + } 6421 + defer func() { 6422 + if err == io.EOF { 6423 + err = io.ErrUnexpectedEOF 6424 + } 6425 + }() 6426 + 6427 + if maj != cbg.MajMap { 6428 + return fmt.Errorf("cbor input should be of type map") 6429 + } 6430 + 6431 + if extra > cbg.MaxLength { 6432 + return fmt.Errorf("BadgeDisplay_BadgeSelection: map struct too large (%d)", extra) 6433 + } 6434 + 6435 + n := extra 6436 + 6437 + nameBuf := make([]byte, 9) 6438 + for i := uint64(0); i < n; i++ { 6439 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 6440 + if err != nil { 6441 + return err 6442 + } 6443 + 6444 + if !ok { 6445 + // Field doesn't exist on this type, so ignore it 6446 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 6447 + return err 6448 + } 6449 + continue 6450 + } 6451 + 6452 + switch string(nameBuf[:nameLen]) { 6453 + // t.Issuance (string) (string) 6454 + case "issuance": 6455 + 6456 + { 6457 + b, err := cr.ReadByte() 6458 + if err != nil { 6459 + return err 6460 + } 6461 + if b != cbg.CborNull[0] { 6462 + if err := cr.UnreadByte(); err != nil { 6463 + return err 6464 + } 6465 + 6466 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6467 + if err != nil { 6468 + return err 6469 + } 6470 + 6471 + t.Issuance = (*string)(&sval) 6472 + } 6473 + } 6474 + // t.BadgeType (string) (string) 6475 + case "badgeType": 6476 + 6477 + { 6478 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6479 + if err != nil { 6480 + return err 6481 + } 6482 + 6483 + t.BadgeType = string(sval) 6484 + } 6485 + 6486 + default: 6487 + // Field doesn't exist on this type, so ignore it 6488 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 6489 + return err 6490 + } 6491 + } 6492 + } 6493 + 6494 + return nil 6495 + }
+4 -2
pkg/streamplace/chatdefs.go
··· 16 type ChatDefs_MessageView struct { 17 LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.chat.defs#messageView"` 18 Author *appbsky.ActorDefs_ProfileViewBasic `json:"author" cborgen:"author"` 19 - ChatProfile *ChatProfile `json:"chatProfile,omitempty" cborgen:"chatProfile,omitempty"` 20 - Cid string `json:"cid" cborgen:"cid"` 21 // deleted: If true, this message has been deleted or labeled and should be cleared from the cache 22 Deleted *bool `json:"deleted,omitempty" cborgen:"deleted,omitempty"` 23 IndexedAt string `json:"indexedAt" cborgen:"indexedAt"`
··· 16 type ChatDefs_MessageView struct { 17 LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.chat.defs#messageView"` 18 Author *appbsky.ActorDefs_ProfileViewBasic `json:"author" cborgen:"author"` 19 + // badges: Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable. Tokens are looked up in badges.json for display info. 20 + Badges []*BadgeDefs_BadgeView `json:"badges,omitempty" cborgen:"badges,omitempty"` 21 + ChatProfile *ChatProfile `json:"chatProfile,omitempty" cborgen:"chatProfile,omitempty"` 22 + Cid string `json:"cid" cborgen:"cid"` 23 // deleted: If true, this message has been deleted or labeled and should be cleared from the cache 24 Deleted *bool `json:"deleted,omitempty" cborgen:"deleted,omitempty"` 25 IndexedAt string `json:"indexedAt" cborgen:"indexedAt"`
+97
pnpm-lock.yaml
··· 728 sharp: 729 specifier: ^0.32.5 730 version: 0.32.6 731 starlight-openapi: 732 specifier: ^0.17.0 733 version: 0.17.0(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)))(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))(openapi-types@12.1.3) 734 starlight-openapi-rapidoc: 735 specifier: ^0.8.1-beta 736 version: 0.8.1-beta(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)))(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))(openapi-types@12.1.3) 737 streamplace: 738 specifier: workspace:* 739 version: link:../streamplace 740 741 js/streamplace: 742 dependencies: ··· 4905 4906 '@types/normalize-package-data@2.4.4': 4907 resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} 4908 4909 '@types/prop-types@15.7.12': 4910 resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==} ··· 7905 resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} 7906 engines: {node: '>=8'} 7907 7908 has-property-descriptors@1.0.2: 7909 resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} 7910 ··· 8354 8355 iron-webcrypto@1.2.1: 8356 resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} 8357 8358 is-alphabetical@2.0.1: 8359 resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} ··· 11757 standard-as-callback@2.1.0: 11758 resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} 11759 11760 starlight-openapi-rapidoc@0.8.1-beta: 11761 resolution: {integrity: sha512-CicjuydKZsO8jZvhrtzz+GGWyfHnOZCZY/ww562H58CP/zSnskWcmcgVnCuD7DkKfi0ofvqumyyee1KgfkVipQ==} 11762 engines: {node: '>=18.14.1'} ··· 11771 '@astrojs/markdown-remark': '>=6.0.1' 11772 '@astrojs/starlight': '>=0.34.0' 11773 astro: '>=5.5.0' 11774 11775 statuses@1.5.0: 11776 resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} ··· 11979 resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} 11980 engines: {node: '>= 8.0'} 11981 11982 supports-color@5.5.0: 11983 resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} 11984 engines: {node: '>=4'} ··· 11994 supports-hyperlinks@2.3.0: 11995 resolution: {integrity: sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==} 11996 engines: {node: '>=8'} 11997 11998 supports-preserve-symlinks-flag@1.0.0: 11999 resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} ··· 12058 resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==} 12059 engines: {node: '>=8'} 12060 12061 terser-webpack-plugin@5.3.10: 12062 resolution: {integrity: sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==} 12063 engines: {node: '>= 10.13.0'} ··· 19470 19471 '@types/normalize-package-data@2.4.4': {} 19472 19473 '@types/prop-types@15.7.12': {} 19474 19475 '@types/qrcode@1.5.5': ··· 23321 23322 has-flag@4.0.0: {} 23323 23324 has-property-descriptors@1.0.2: 23325 dependencies: 23326 es-define-property: 1.0.0 ··· 23994 ipaddr.js@2.2.0: {} 23995 23996 iron-webcrypto@1.2.1: {} 23997 23998 is-alphabetical@2.0.1: {} 23999 ··· 28427 28428 standard-as-callback@2.1.0: {} 28429 28430 starlight-openapi-rapidoc@0.8.1-beta(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)))(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))(openapi-types@12.1.3): 28431 dependencies: 28432 '@astrojs/starlight': 0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)) ··· 28447 url-template: 3.1.1 28448 transitivePeerDependencies: 28449 - openapi-types 28450 28451 statuses@1.5.0: {} 28452 ··· 28661 transitivePeerDependencies: 28662 - supports-color 28663 28664 supports-color@5.5.0: 28665 dependencies: 28666 has-flag: 3.0.0 ··· 28677 dependencies: 28678 has-flag: 4.0.0 28679 supports-color: 7.2.0 28680 28681 supports-preserve-symlinks-flag@1.0.0: {} 28682 ··· 28772 dependencies: 28773 ansi-escapes: 4.3.2 28774 supports-hyperlinks: 2.3.0 28775 28776 terser-webpack-plugin@5.3.10(@swc/core@1.15.4(@swc/helpers@0.5.17))(webpack@5.94.0(@swc/core@1.15.4(@swc/helpers@0.5.17))): 28777 dependencies:
··· 728 sharp: 729 specifier: ^0.32.5 730 version: 0.32.6 731 + starlight-links-validator: 732 + specifier: ^0.19.2 733 + version: 0.19.2(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)))(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)) 734 starlight-openapi: 735 specifier: ^0.17.0 736 version: 0.17.0(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)))(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))(openapi-types@12.1.3) 737 starlight-openapi-rapidoc: 738 specifier: ^0.8.1-beta 739 version: 0.8.1-beta(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)))(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))(openapi-types@12.1.3) 740 + starlight-sidebar-swipe: 741 + specifier: ^0.1.1 742 + version: 0.1.1(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))) 743 streamplace: 744 specifier: workspace:* 745 version: link:../streamplace 746 + devDependencies: 747 + starlight-sidebar-topics: 748 + specifier: ^0.6.2 749 + version: 0.6.2(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))) 750 751 js/streamplace: 752 dependencies: ··· 4915 4916 '@types/normalize-package-data@2.4.4': 4917 resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} 4918 + 4919 + '@types/picomatch@3.0.2': 4920 + resolution: {integrity: sha512-n0i8TD3UDB7paoMMxA3Y65vUncFJXjcUf7lQY7YyKGl6031FNjfsLs6pdLFCy2GNFxItPJG8GvvpbZc2skH7WA==} 4921 4922 '@types/prop-types@15.7.12': 4923 resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==} ··· 7918 resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} 7919 engines: {node: '>=8'} 7920 7921 + has-flag@5.0.1: 7922 + resolution: {integrity: sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==} 7923 + engines: {node: '>=12'} 7924 + 7925 has-property-descriptors@1.0.2: 7926 resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} 7927 ··· 8371 8372 iron-webcrypto@1.2.1: 8373 resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} 8374 + 8375 + is-absolute-url@4.0.1: 8376 + resolution: {integrity: sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A==} 8377 + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} 8378 8379 is-alphabetical@2.0.1: 8380 resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} ··· 11778 standard-as-callback@2.1.0: 11779 resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} 11780 11781 + starlight-links-validator@0.19.2: 11782 + resolution: {integrity: sha512-IHeK3R78fsmv53VfRkGbXkwK1CQEUBHM9QPzBEyoAxjZ/ssi5gjV+F4oNNUppTR48iPp+lEY0MTAmvkX7yNnkw==} 11783 + engines: {node: '>=18.17.1'} 11784 + peerDependencies: 11785 + '@astrojs/starlight': '>=0.32.0' 11786 + astro: '>=5.1.5' 11787 + 11788 starlight-openapi-rapidoc@0.8.1-beta: 11789 resolution: {integrity: sha512-CicjuydKZsO8jZvhrtzz+GGWyfHnOZCZY/ww562H58CP/zSnskWcmcgVnCuD7DkKfi0ofvqumyyee1KgfkVipQ==} 11790 engines: {node: '>=18.14.1'} ··· 11799 '@astrojs/markdown-remark': '>=6.0.1' 11800 '@astrojs/starlight': '>=0.34.0' 11801 astro: '>=5.5.0' 11802 + 11803 + starlight-sidebar-swipe@0.1.1: 11804 + resolution: {integrity: sha512-Q+xv7LSpSLCG3yQaEmZX4Qpks9dcIEc+FBA0Ql+LbLMO9IMBXt8S2zK5wJDhjJn5lbI0i0ipyP375T1GrVS8ig==} 11805 + engines: {node: ^18.17.1 || ^20.3.0 || >=21.0.0} 11806 + peerDependencies: 11807 + '@astrojs/starlight': '>=0.30' 11808 + 11809 + starlight-sidebar-topics@0.6.2: 11810 + resolution: {integrity: sha512-SNCTUZS/hcVor0ZcaXbaSVU37+V+qtvzNirkvnOg3Mqu/awuGpthkH5+uKpiZqWxLffp6TrOlsv5E5QsxrndNg==} 11811 + engines: {node: '>=18'} 11812 + peerDependencies: 11813 + '@astrojs/starlight': '>=0.32.0' 11814 11815 statuses@1.5.0: 11816 resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} ··· 12019 resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} 12020 engines: {node: '>= 8.0'} 12021 12022 + supports-color@10.2.2: 12023 + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} 12024 + engines: {node: '>=18'} 12025 + 12026 supports-color@5.5.0: 12027 resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} 12028 engines: {node: '>=4'} ··· 12038 supports-hyperlinks@2.3.0: 12039 resolution: {integrity: sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==} 12040 engines: {node: '>=8'} 12041 + 12042 + supports-hyperlinks@4.4.0: 12043 + resolution: {integrity: sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg==} 12044 + engines: {node: '>=20'} 12045 12046 supports-preserve-symlinks-flag@1.0.0: 12047 resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} ··· 12106 resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==} 12107 engines: {node: '>=8'} 12108 12109 + terminal-link@5.0.0: 12110 + resolution: {integrity: sha512-qFAy10MTMwjzjU8U16YS4YoZD+NQLHzLssFMNqgravjbvIPNiqkGFR4yjhJfmY9R5OFU7+yHxc6y+uGHkKwLRA==} 12111 + engines: {node: '>=20'} 12112 + 12113 terser-webpack-plugin@5.3.10: 12114 resolution: {integrity: sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==} 12115 engines: {node: '>= 10.13.0'} ··· 19522 19523 '@types/normalize-package-data@2.4.4': {} 19524 19525 + '@types/picomatch@3.0.2': {} 19526 + 19527 '@types/prop-types@15.7.12': {} 19528 19529 '@types/qrcode@1.5.5': ··· 23375 23376 has-flag@4.0.0: {} 23377 23378 + has-flag@5.0.1: {} 23379 + 23380 has-property-descriptors@1.0.2: 23381 dependencies: 23382 es-define-property: 1.0.0 ··· 24050 ipaddr.js@2.2.0: {} 24051 24052 iron-webcrypto@1.2.1: {} 24053 + 24054 + is-absolute-url@4.0.1: {} 24055 24056 is-alphabetical@2.0.1: {} 24057 ··· 28485 28486 standard-as-callback@2.1.0: {} 28487 28488 + starlight-links-validator@0.19.2(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)))(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)): 28489 + dependencies: 28490 + '@astrojs/starlight': 0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)) 28491 + '@types/picomatch': 3.0.2 28492 + astro: 5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2) 28493 + github-slugger: 2.0.0 28494 + hast-util-from-html: 2.0.3 28495 + hast-util-has-property: 3.0.0 28496 + is-absolute-url: 4.0.1 28497 + kleur: 4.1.5 28498 + mdast-util-mdx-jsx: 3.2.0 28499 + mdast-util-to-string: 4.0.0 28500 + picomatch: 4.0.2 28501 + terminal-link: 5.0.0 28502 + unist-util-visit: 5.0.0 28503 + transitivePeerDependencies: 28504 + - supports-color 28505 + 28506 starlight-openapi-rapidoc@0.8.1-beta(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)))(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))(openapi-types@12.1.3): 28507 dependencies: 28508 '@astrojs/starlight': 0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)) ··· 28523 url-template: 3.1.1 28524 transitivePeerDependencies: 28525 - openapi-types 28526 + 28527 + starlight-sidebar-swipe@0.1.1(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))): 28528 + dependencies: 28529 + '@astrojs/starlight': 0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)) 28530 + 28531 + starlight-sidebar-topics@0.6.2(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))): 28532 + dependencies: 28533 + '@astrojs/starlight': 0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)) 28534 + picomatch: 4.0.2 28535 28536 statuses@1.5.0: {} 28537 ··· 28746 transitivePeerDependencies: 28747 - supports-color 28748 28749 + supports-color@10.2.2: {} 28750 + 28751 supports-color@5.5.0: 28752 dependencies: 28753 has-flag: 3.0.0 ··· 28764 dependencies: 28765 has-flag: 4.0.0 28766 supports-color: 7.2.0 28767 + 28768 + supports-hyperlinks@4.4.0: 28769 + dependencies: 28770 + has-flag: 5.0.1 28771 + supports-color: 10.2.2 28772 28773 supports-preserve-symlinks-flag@1.0.0: {} 28774 ··· 28864 dependencies: 28865 ansi-escapes: 4.3.2 28866 supports-hyperlinks: 2.3.0 28867 + 28868 + terminal-link@5.0.0: 28869 + dependencies: 28870 + ansi-escapes: 7.0.0 28871 + supports-hyperlinks: 4.4.0 28872 28873 terser-webpack-plugin@5.3.10(@swc/core@1.15.4(@swc/helpers@0.5.17))(webpack@5.94.0(@swc/core@1.15.4(@swc/helpers@0.5.17))): 28874 dependencies: