Live video on the AT Protocol

Merge branch 'next' into fix-char-limit-chat

ElshadHu d4ad562d 2247e888

+2430 -233
+9 -5
js/app/components/home/cards.tsx
··· 49 49 style={[ 50 50 zero.flex.values[1], 51 51 { 52 + borderCurve: "continuous", 52 53 backgroundColor: theme.colors.muted, 53 54 borderRadius, 54 55 overflow: "hidden", 55 56 borderColor: theme.colors.mutedForeground + 80, 56 - borderWidth: 2, 57 + borderWidth: isWeb ? 1 : 0, 57 58 alignItems: layoutHorizontal ? "center" : "stretch", 58 59 flexDirection: layoutHorizontal ? "row" : "column", 59 60 }, 60 61 ]} 61 62 > 62 - {/* Thumbnail Section */} 63 + {/* Thumbnail */} 63 64 <View 64 65 style={[ 65 66 { ··· 67 68 minWidth: layoutHorizontal ? "63%" : "100%", 68 69 // native seems to be unable to adjust widths properly? 69 70 maxHeight: !isWeb ? "76.5%" : "100%", 70 - borderRadius, 71 - overflow: "hidden", 72 71 position: "relative", 73 72 alignSelf: layoutHorizontal ? "auto" : "center", 74 73 backgroundColor: theme.colors.card, ··· 77 76 > 78 77 <Image 79 78 source={{ uri: `${url}/${thumbnailUrl}`, width: 160, height: 90 }} 80 - style={{ width: "100%", height: "100%", aspectRatio: 16 / 9 }} 79 + style={{ 80 + width: "100%", 81 + height: "100%", 82 + aspectRatio: 16 / 9, 83 + }} 81 84 resizeMode="contain" 82 85 /> 83 86 {isLive && ( ··· 193 196 ]} 194 197 numberOfLines={1} 195 198 ellipsizeMode="tail" 199 + leading="tight" 196 200 > 197 201 @{streamerName} 198 202 </Text>
+46 -1
js/app/components/mobile/chat.tsx
··· 3 3 ChatBox, 4 4 Loader, 5 5 Resizable, 6 + StreamNotificationProvider, 6 7 Text, 7 8 useHandle, 8 9 useLivestreamInfo, ··· 54 55 { translateX: sidebarOffset.value }, 55 56 { translateY: -kb.keyboardHeight }, 56 57 ], 57 - opacity: sidebarOpacity.value, 58 + })); 59 + 60 + const notificationOffsetStyle = useAnimatedStyle(() => ({ 61 + transform: [{ translateX: -sidebarOffset.value }], 58 62 })); 59 63 60 64 return ( ··· 88 92 ]} 89 93 > 90 94 <View style={{ flex: 1, position: "relative" }}> 95 + <Animated.View 96 + style={[ 97 + { 98 + position: "absolute", 99 + top: 1, 100 + right: 1, 101 + zIndex: 2, 102 + width: "100%", 103 + minWidth: 350, 104 + pointerEvents: "none", 105 + transformOrigin: "top right", 106 + }, 107 + notificationOffsetStyle, 108 + ]} 109 + > 110 + <StreamNotificationProvider position="top" /> 111 + </Animated.View> 91 112 <ChatPanel /> 92 113 </View> 93 114 </Animated.View> ··· 110 131 <Resizable 111 132 isPlayerRatioGreater={isPlayerRatioGreater} 112 133 startingPercentage={0.4} 134 + renderAbove={(isCollapsed) => ( 135 + <StreamNotificationProvider position="bottom" /> 136 + )} 113 137 > 114 138 <ChatPanel /> 115 139 </Resizable> ··· 149 173 <Chat /> 150 174 </View> 151 175 <View style={[layout.flex.column, gap.all[2]]}> 176 + {/* 177 + // in case one needs this again 178 + 179 + <Pressable 180 + onPress={() => 181 + StreamNotifications.activate("Stream notification activated!") 182 + } 183 + style={[ 184 + layout.flex.row, 185 + layout.flex.center, 186 + { 187 + padding: 12, 188 + borderRadius: borderRadius.xl, 189 + backgroundColor: "rgba(255, 255, 255, 0.05)", 190 + }, 191 + ]} 192 + > 193 + <Text style={{ color: "rgba(255, 255, 255, 0.7)", fontSize: 12 }}> 194 + Activate Stream Notification 195 + </Text> 196 + </Pressable>*/} 152 197 {agent?.did ? ( 153 198 <ChatBox 154 199 emojiData={emojiData}
+41 -23
js/app/components/mobile/player.tsx
··· 43 43 export function Player( 44 44 props: Partial<PlayerProps> & { 45 45 setFullscreen?: (fullscreen: boolean) => void; 46 + onTeleport?: (targetHandle: string, targetDID: string) => void; 46 47 }, 47 48 ) { 48 49 return ( ··· 60 61 function PlayerWithProvider( 61 62 props: Partial<PlayerProps> & { 62 63 setFullscreen?: (fullscreen: boolean) => void; 64 + onTeleport?: (targetHandle: string, targetDID: string) => void; 63 65 }, 64 66 ) { 65 67 const [showChat, setShowChat] = useState(true); ··· 188 190 ); 189 191 } 190 192 193 + const defaultHandleTeleport = (targetHandle: string, targetDID: string) => { 194 + navigation.navigate("Home", { 195 + screen: "Stream", 196 + params: { user: targetHandle }, 197 + }); 198 + }; 199 + 200 + const handleTeleport = props.onTeleport || defaultHandleTeleport; 201 + 191 202 return ( 192 - <View 193 - style={{ 194 - flexDirection: chatVisible ? "row" : "column", 195 - flex: 1, 196 - width: "100%", 197 - height: "100%", 198 - }} 199 - > 200 - <PlayerInner 201 - {...props} 202 - showChat={showChat} 203 - setShowChat={setShowChat} 204 - showUnavailable={showUnavailable} 205 - /> 206 - {shouldShowChatSidePanel ? ( 207 - <DesktopChatPanel 208 - chatVisible={chatVisible} 209 - chatPanelWidth={chatPanelWidth} 210 - /> 211 - ) : ( 212 - !showUnavailable && <MobileUi /> 213 - )} 214 - </View> 203 + <RotationProvider enabled={Platform.OS !== "web"}> 204 + <LivestreamProvider src={props.src ?? ""} onTeleport={handleTeleport}> 205 + <StatusBar hidden={true} /> 206 + <PlayerProvider defaultId={props.playerId || undefined}> 207 + <View 208 + style={{ 209 + flexDirection: chatVisible ? "row" : "column", 210 + flex: 1, 211 + width: "100%", 212 + height: "100%", 213 + }} 214 + > 215 + <PlayerInner 216 + {...props} 217 + showChat={showChat} 218 + setShowChat={setShowChat} 219 + showUnavailable={showUnavailable} 220 + /> 221 + {shouldShowChatSidePanel ? ( 222 + <DesktopChatPanel 223 + chatVisible={chatVisible} 224 + chatPanelWidth={chatPanelWidth} 225 + /> 226 + ) : ( 227 + !showUnavailable && <MobileUi /> 228 + )} 229 + </View> 230 + </PlayerProvider> 231 + </LivestreamProvider> 232 + </RotationProvider> 215 233 ); 216 234 } 217 235
-64
js/app/components/provider/CurrentToast.tsx
··· 1 - import { Text, zero } from "@streamplace/components"; 2 - import { Platform, Pressable, View } from "react-native"; 3 - 4 - const isWeb = Platform.OS === "web"; 5 - 6 - // Note: Toast functionality removed - this is now a placeholder implementation 7 - // In a real app, you might want to use a toast library like react-native-toast-message 8 - // or implement a simple alert/modal system 9 - 10 - export function CurrentToast() { 11 - // Toast functionality removed - would need replacement with simple modal or alert 12 - return null; 13 - } 14 - 15 - export function ToastControl() { 16 - // Note: This was a demo component for testing toasts 17 - return ( 18 - <View style={[{ gap: 8 }, zero.layout.flex.alignCenter]}> 19 - <Text style={[{ fontSize: 18, fontWeight: "bold" }]}> 20 - Toast demo (disabled) 21 - </Text> 22 - <View 23 - style={[ 24 - zero.layout.flex.row, 25 - { gap: 8 }, 26 - zero.layout.flex.justifyCenter, 27 - ]} 28 - > 29 - <Pressable 30 - style={[ 31 - { 32 - backgroundColor: "#0066cc", 33 - padding: 12, 34 - borderRadius: 8, 35 - alignItems: "center", 36 - }, 37 - ]} 38 - onPress={() => { 39 - // Would show toast: "Successfully saved!" with message: "Don't worry, we've got your data." 40 - console.log("Toast would show: Successfully saved!"); 41 - }} 42 - > 43 - <Text style={{ color: "white" }}>Show</Text> 44 - </Pressable> 45 - <Pressable 46 - style={[ 47 - { 48 - backgroundColor: "#666", 49 - padding: 12, 50 - borderRadius: 8, 51 - alignItems: "center", 52 - }, 53 - ]} 54 - onPress={() => { 55 - // Would hide toast 56 - console.log("Toast would hide"); 57 - }} 58 - > 59 - <Text style={{ color: "white" }}>Hide</Text> 60 - </Pressable> 61 - </View> 62 - </View> 63 - ); 64 - }
+1 -1
js/app/package.json
··· 1 1 { 2 2 "name": "@streamplace/app", 3 3 "main": "./src/entrypoint.tsx", 4 - "version": "0.9.0", 4 + "version": "0.9.1", 5 5 "runtimeVersion": "0.7.2", 6 6 "scripts": { 7 7 "start": "npx expo start -c --port 38081",
+23 -3
js/app/src/screens/mobile-stream.tsx
··· 1 + import { useNavigation } from "@react-navigation/native"; 1 2 import { 2 3 KeepAwake, 3 4 LivestreamProvider, ··· 33 34 user, 34 35 src, 35 36 extraProps, 37 + onTeleport, 36 38 }: { 37 39 user: string; 38 40 src: string; 39 41 extraProps: Partial<PlayerProps>; 42 + onTeleport?: (targetHandle: string, targetDID: string) => void; 40 43 }) { 41 44 const problems = useLivestreamStore((x) => x.problems); 42 45 ··· 52 55 <> 53 56 <KeepAwake /> 54 57 <FullscreenProvider> 55 - <Player src={src} {...extraProps} /> 58 + <Player key={src} src={src} {...extraProps} onTeleport={onTeleport} /> 56 59 </FullscreenProvider> 57 60 </> 58 61 ); ··· 60 63 61 64 export default function MobileStream({ route }) { 62 65 const { user, protocol, url } = route?.params ?? {}; 66 + let navi = useNavigation(); 63 67 let extraProps: Partial<PlayerProps> = {}; 64 68 if (isWeb) { 65 69 extraProps = queryToProps(new URLSearchParams(window.location.search)); ··· 69 73 src = url; 70 74 } 71 75 76 + const handleTeleport = (targetHandle: string, targetDID?: string) => { 77 + if (!navi || (!targetHandle && !targetDID)) { 78 + console.error("Navigation or target info missing for teleport"); 79 + return; 80 + } 81 + navi.navigate("Home", { 82 + screen: "Stream", 83 + params: { user: targetHandle }, 84 + }); 85 + }; 86 + 72 87 return ( 73 - <LivestreamProvider src={src}> 88 + <LivestreamProvider key={src} src={src} onTeleport={handleTeleport}> 74 89 <PlayerProvider> 75 - <MobileStreamInner user={user} src={src} extraProps={extraProps} /> 90 + <MobileStreamInner 91 + user={user} 92 + src={src} 93 + extraProps={extraProps} 94 + onTeleport={handleTeleport} 95 + /> 76 96 </PlayerProvider> 77 97 </LivestreamProvider> 78 98 );
+1 -1
js/components/package.json
··· 1 1 { 2 2 "name": "@streamplace/components", 3 - "version": "0.9.0", 3 + "version": "0.9.1", 4 4 "description": "Streamplace React (Native) Components", 5 5 "main": "dist/index.js", 6 6 "types": "src/index.tsx",
+84 -24
js/components/src/components/chat/chat-box.tsx
··· 1 1 import Picker from "@emoji-mart/react"; 2 2 import { AtSignIcon, ExternalLink, X } from "lucide-react-native"; 3 + import { env } from "process"; 3 4 import { useEffect, useMemo, useRef, useState } from "react"; 4 5 import { Platform, Pressable, TextInput } from "react-native"; 5 6 import { ChatMessageViewHydrated } from "streamplace"; 6 - import { 7 - Button, 8 - Loader, 9 - Text, 10 - toast, 11 - useChat, 12 - useCreateChatMessage, 13 - useLivestream, 14 - useReplyToMessage, 15 - useSetReplyToMessage, 16 - useTheme, 17 - View, 18 - } from "../../"; 7 + import { Button, Loader, Text, toast, useTheme, View } from "../../"; 8 + import { handleSlashCommand } from "../../lib/slash-commands"; 9 + import { registerTeleportCommand } from "../../lib/slash-commands/teleport"; 10 + import { StreamNotifications } from "../../lib/stream-notifications"; 11 + import { SystemMessages } from "../../lib/system-messages"; 19 12 import { 20 13 borders, 21 14 flex, ··· 29 22 r, 30 23 w, 31 24 } from "../../lib/theme/atoms"; 25 + import { 26 + useChat, 27 + useCreateChatMessage, 28 + useLivestream, 29 + useLivestreamStore, 30 + useReplyToMessage, 31 + useSetReplyToMessage, 32 + } from "../../livestream-store"; 33 + import { useDID, usePDSAgent } from "../../streamplace-store"; 32 34 import { Textarea } from "../ui/textarea"; 33 35 import { RenderChatMessage } from "./chat-message"; 34 36 import { EmojiData, EmojiSuggestions } from "./emoji-suggestions"; ··· 75 77 const setReplyToMessage = useSetReplyToMessage(); 76 78 const textAreaRef = useRef<TextInput>(null); 77 79 80 + const pdsAgent = usePDSAgent(); 81 + const userDID = useDID(); 82 + const setActiveTeleportUri = useLivestreamStore( 83 + (state) => state.setActiveTeleportUri, 84 + ); 85 + 86 + useEffect(() => { 87 + if (pdsAgent && userDID) { 88 + registerTeleportCommand(pdsAgent, userDID, setActiveTeleportUri); 89 + } 90 + }, [pdsAgent, userDID, setActiveTeleportUri]); 91 + 78 92 const authors = useMemo(() => { 79 93 if (!chat) return null; 80 94 return chat.reduce((acc, msg) => { ··· 85 99 return acc; 86 100 }, new Map<string, ChatMessageViewHydrated["chatProfile"]>()); 87 101 }, [chat]); 102 + 103 + useEffect(() => { 104 + if (pdsAgent && linfo?.author?.did && pdsAgent.did === linfo.author.did) { 105 + registerTeleportCommand(pdsAgent, pdsAgent.did, setActiveTeleportUri); 106 + } 107 + }, [pdsAgent, linfo?.author?.did, setActiveTeleportUri]); 88 108 89 109 const handleMentionSelect = (handle: string) => { 90 110 const beforeAt = message.slice(0, message.lastIndexOf("@")); ··· 234 254 } 235 255 }; 236 256 237 - const submit = () => { 257 + const submit = async () => { 238 258 if (!message.trim()) return; 239 259 if ([...message].length > 300) { 240 260 toast.show( ··· 247 267 ); 248 268 return; 249 269 } 270 + 271 + const messageText = message; 250 272 setMessage(""); 251 273 setReplyToMessage(null); 252 274 275 + if (messageText.startsWith("/")) { 276 + const result = await handleSlashCommand(messageText); 277 + if (result.handled) { 278 + if (result.error) { 279 + console.error("Slash command error:", result.error); 280 + SystemMessages.commandError(result.error); 281 + } 282 + return; 283 + } 284 + } 253 285 setSubmitting(true); 254 - createChatMessage({ 255 - text: message, 256 - reply: replyTo || undefined, 257 - }); 258 - setSubmitting(false); 259 286 260 - // if we press "send" button, we want the same action as pressing "Enter" 261 - // if we're already focused no need to do extra work 287 + try { 288 + const result = await handleSlashCommand(messageText); 289 + 290 + if (result.handled) { 291 + if (result.error) { 292 + console.error("Slash command error:", result.error); 293 + } 294 + } else { 295 + createChatMessage({ 296 + text: messageText, 297 + reply: replyTo || undefined, 298 + }); 299 + } 300 + } catch (err) { 301 + console.error("Error submitting message:", err); 302 + } finally { 303 + setSubmitting(false); 304 + } 305 + 262 306 if (textAreaRef.current && !textAreaRef.current.isFocused()) { 263 307 textAreaRef.current.focus(); 264 308 requestAnimationFrame(() => { ··· 472 516 { justifyContent: "flex-end" }, 473 517 ]} 474 518 > 519 + {env.NODE_ENV === "development" && ( 520 + <Button 521 + variant="secondary" 522 + style={{ borderRadius: 16 }} 523 + width="min" 524 + onPress={() => { 525 + StreamNotifications.teleport({ 526 + targetHandle: "test.bsky.social", 527 + targetDID: "did:plc:test", 528 + countdown: 30, 529 + canCancel: true, 530 + onDismiss: (reason) => 531 + console.log("teleport dismissed:", reason), 532 + }); 533 + }} 534 + > 535 + Test Notification 536 + </Button> 537 + )} 475 538 <Button 476 539 variant="secondary" 477 540 style={{ borderRadius: 16, maxWidth: 44, aspectRatio: 1 }} 478 541 aria-label="Insert Mention" 479 542 onPress={() => { 480 - // if the last character is not @, add it 481 543 !message.endsWith("@") && setMessage(message + "@"); 482 - // get all the text after the last @ 483 544 const atIndex = message.lastIndexOf("@"); 484 545 const searchText = message.slice(atIndex + 1).toLowerCase(); 485 546 updateSuggestions(searchText); 486 547 setShowSuggestions(true); 487 - // focus the textarea 488 548 textAreaRef.current?.focus(); 489 549 }} 490 550 >
+1 -1
js/components/src/components/chat/chat-message.tsx
··· 76 76 } 77 77 }; 78 78 79 - const RichTextMessage = ({ 79 + export const RichTextMessage = ({ 80 80 text, 81 81 facets, 82 82 }: {
+4
js/components/src/components/chat/chat.tsx
··· 13 13 } from "react-native-reanimated"; 14 14 import { ChatMessageViewHydrated } from "streamplace"; 15 15 import { 16 + getSystemMessageType, 16 17 SystemMessage, 18 + SystemMessageType, 17 19 Text, 18 20 useChat, 19 21 usePlayerStore, ··· 171 173 if (item.author.did === "did:sys:system") { 172 174 return ( 173 175 <SystemMessage 176 + variant={getSystemMessageType(item) || SystemMessageType.notification} 174 177 timestamp={new Date(item.record.createdAt)} 175 178 title={item.record.text} 179 + facets={item.record.facets} 176 180 /> 177 181 ); 178 182 }
+14 -5
js/components/src/components/chat/system-message.tsx
··· 1 1 import { View } from "react-native"; 2 - import { flex, gap, layout, ml, pb, pl, px, w } from "../../ui"; 3 - import { atoms } from "../ui"; 2 + import { Main } from "streamplace/src/lexicons/types/place/stream/richtext/facet"; 3 + import { SystemMessageType } from "../../lib/system-messages"; 4 + import { colors, flex, gap, layout, ml, pb, pl, px, w } from "../../ui"; 4 5 import { Code, Text } from "../ui/text"; 6 + import { RichTextMessage } from "./chat-message"; 5 7 6 8 interface SystemMessageProps { 9 + variant: SystemMessageType; 7 10 title: string; 8 11 timestamp: Date; 12 + facets?: Main[]; 9 13 } 10 14 11 - export function SystemMessage({ title, timestamp }: SystemMessageProps) { 15 + export function SystemMessage({ 16 + variant, 17 + title, 18 + timestamp, 19 + facets, 20 + }: SystemMessageProps) { 12 21 return ( 13 22 <View style={[w.percent[100], px[2], pb[2]]}> 14 23 <Code color="muted" tracking="widest" style={[pl[12], ml[1]]}> ··· 18 27 <Text 19 28 style={{ 20 29 fontVariant: ["tabular-nums"], 21 - color: atoms.colors.gray[300], 30 + color: colors.gray[400], 22 31 }} 23 32 > 24 33 {timestamp.toLocaleTimeString([], { ··· 28 37 })} 29 38 </Text> 30 39 <Text weight="bold" color="default" style={[flex.shrink[1]]}> 31 - {title} 40 + <RichTextMessage facets={facets} text={title} /> 32 41 </Text> 33 42 </View> 34 43 </View>
+2 -1
js/components/src/components/mobile-player/shared.tsx
··· 1 1 import { useMemo } from "react"; 2 - import { PlayerProtocol, useStreamplaceStore } from "../.."; 2 + import { PlayerProtocol } from "../../player-store/player-state"; 3 + import { useStreamplaceStore } from "../../streamplace-store"; 3 4 4 5 const protocolSuffixes = { 5 6 m3u8: PlayerProtocol.HLS,
+5
js/components/src/components/stream-notification/index.ts
··· 1 + export { StreamNotificationProvider } from "./stream-notification"; 2 + export { 3 + streamNotification, 4 + streamNotificationManager, 5 + } from "./stream-notification-manager";
+140
js/components/src/components/stream-notification/stream-notification-manager.ts
··· 1 + export type NotificationConfig = { 2 + id?: string; 3 + message?: string; 4 + render?: ( 5 + isExiting: boolean, 6 + onDismiss: (reason?: "user" | "auto") => void, 7 + startTime?: number, 8 + ) => React.ReactNode; 9 + duration?: number; // seconds, 0 = manual dismiss only 10 + actionLabel?: string; 11 + onAction?: () => void; 12 + onDismiss?: (reason?: "user" | "auto") => void; 13 + variant?: "default" | "info" | "warning"; 14 + }; 15 + 16 + export type StreamNotification = NotificationConfig & { 17 + id: string; 18 + visible: boolean; 19 + shouldDismiss?: boolean; 20 + dismissReason?: "user" | "auto"; 21 + startTime?: number; 22 + }; 23 + 24 + type Listener = (notifications: StreamNotification[]) => void; 25 + 26 + class StreamNotificationManager { 27 + private notifications: StreamNotification[] = []; 28 + private listeners: Set<Listener> = new Set(); 29 + private dismissTimers: Map<string, NodeJS.Timeout> = new Map(); 30 + 31 + show(config: NotificationConfig) { 32 + const notification: StreamNotification = { 33 + id: config.id || `notification-${Date.now()}`, 34 + message: config.message, 35 + render: config.render, 36 + duration: config.duration ?? 5, 37 + actionLabel: config.actionLabel, 38 + onAction: config.onAction, 39 + onDismiss: config.onDismiss, 40 + variant: config.variant ?? "default", 41 + visible: true, 42 + startTime: Date.now(), 43 + }; 44 + 45 + // if notification with same ID exists, dismiss it first 46 + const existingIndex = this.notifications.findIndex( 47 + (n) => n.id === notification.id, 48 + ); 49 + if (existingIndex !== -1) { 50 + const existingTimer = this.dismissTimers.get(notification.id); 51 + if (existingTimer) { 52 + clearTimeout(existingTimer); 53 + this.dismissTimers.delete(notification.id); 54 + } 55 + this.notifications = this.notifications.filter( 56 + (n) => n.id !== notification.id, 57 + ); 58 + } 59 + 60 + this.notifications = [...this.notifications, notification]; 61 + this.notifyListeners(); 62 + 63 + // auto-dismiss if duration > 0 64 + if (notification.duration && notification.duration > 0) { 65 + const timer = setTimeout(() => { 66 + this.requestDismiss(notification.id, "auto"); 67 + }, notification.duration * 1000); 68 + this.dismissTimers.set(notification.id, timer); 69 + } 70 + } 71 + 72 + requestDismiss(id: string, reason: "user" | "auto" = "user") { 73 + const notification = this.notifications.find((n) => n.id === id); 74 + if (!notification) { 75 + console.log("Notification not found!"); 76 + return; 77 + } 78 + 79 + // mark the notification for dismissal 80 + notification.shouldDismiss = true; 81 + notification.dismissReason = reason; 82 + this.notifyListeners(); 83 + // after 500ms, just hide it for real 84 + setTimeout(() => { 85 + this.hide(id, reason); 86 + }, 500); 87 + } 88 + 89 + hide(id: string, reason: "user" | "auto" = "user") { 90 + console.log("Hide called with id:", id, "reason:", reason); 91 + console.log( 92 + "Current notifications:", 93 + this.notifications.map((n) => n.id), 94 + ); 95 + const notification = this.notifications.find((n) => n.id === id); 96 + if (!notification) { 97 + console.log("Notification not found!"); 98 + return; 99 + } 100 + 101 + const timer = this.dismissTimers.get(id); 102 + if (timer) { 103 + clearTimeout(timer); 104 + this.dismissTimers.delete(id); 105 + } 106 + 107 + this.notifications = this.notifications.filter((n) => n.id !== id); 108 + console.log( 109 + "Remaining notifications:", 110 + this.notifications.map((n) => n.id), 111 + ); 112 + this.notifyListeners(); 113 + 114 + notification.onDismiss?.(reason); 115 + } 116 + 117 + getAll(): StreamNotification[] { 118 + return this.notifications; 119 + } 120 + 121 + subscribe(listener: Listener) { 122 + this.listeners.add(listener); 123 + return () => { 124 + this.listeners.delete(listener); 125 + }; 126 + } 127 + 128 + private notifyListeners() { 129 + this.listeners.forEach((listener) => { 130 + listener(this.notifications); 131 + }); 132 + } 133 + } 134 + 135 + export const streamNotificationManager = new StreamNotificationManager(); 136 + 137 + export const streamNotification = { 138 + show: (config: NotificationConfig) => streamNotificationManager.show(config), 139 + hide: (id: string) => streamNotificationManager.hide(id), 140 + };
+227
js/components/src/components/stream-notification/stream-notification.tsx
··· 1 + import { X } from "lucide-react-native"; 2 + import { useEffect, useState } from "react"; 3 + import { Pressable, StyleSheet, View } from "react-native"; 4 + import Animated, { 5 + Easing, 6 + useAnimatedStyle, 7 + useSharedValue, 8 + withTiming, 9 + } from "react-native-reanimated"; 10 + import { Text, useTheme } from "../../"; 11 + import { 12 + StreamNotification, 13 + streamNotificationManager, 14 + } from "./stream-notification-manager"; 15 + 16 + export function StreamNotificationProvider({ 17 + children = <></>, 18 + position = "top", 19 + }: { 20 + children?: React.ReactNode; 21 + position?: "top" | "bottom"; 22 + }) { 23 + const [notifications, setNotifications] = useState( 24 + streamNotificationManager.getAll(), 25 + ); 26 + 27 + useEffect(() => { 28 + return streamNotificationManager.subscribe(setNotifications); 29 + }, []); 30 + 31 + return ( 32 + <View style={styles.container}> 33 + {children} 34 + {notifications.map((notification, index) => ( 35 + <NotificationItem 36 + key={notification.id} 37 + notification={notification} 38 + index={index} 39 + position={position} 40 + /> 41 + ))} 42 + </View> 43 + ); 44 + } 45 + 46 + function NotificationItem({ 47 + notification, 48 + index, 49 + position, 50 + }: { 51 + notification: StreamNotification; 52 + index: number; 53 + position: "top" | "bottom"; 54 + }) { 55 + const { theme } = useTheme(); 56 + const translateY = useSharedValue(position === "top" ? -100 : 100); 57 + const opacity = useSharedValue(0); 58 + const [isExiting, setIsExiting] = useState(false); 59 + 60 + const NOTIFICATION_HEIGHT = 60; 61 + const NOTIFICATION_GAP = 8; 62 + const offset = 16 + index * (NOTIFICATION_HEIGHT + NOTIFICATION_GAP); 63 + 64 + useEffect(() => { 65 + translateY.value = withTiming(position === "top" ? offset : -offset, { 66 + duration: 300, 67 + easing: Easing.out(Easing.cubic), 68 + }); 69 + opacity.value = withTiming(1, { 70 + duration: 200, 71 + }); 72 + }, [offset, position]); 73 + 74 + useEffect(() => { 75 + if (notification.shouldDismiss && !isExiting) { 76 + setIsExiting(true); 77 + setTimeout(() => { 78 + streamNotificationManager.hide( 79 + notification.id, 80 + notification.dismissReason || "auto", 81 + ); 82 + }, 200); 83 + } 84 + }, [ 85 + notification.shouldDismiss, 86 + isExiting, 87 + notification.id, 88 + notification.dismissReason, 89 + ]); 90 + 91 + useEffect(() => { 92 + if (isExiting) { 93 + translateY.value = withTiming(position === "top" ? -100 : 100, { 94 + duration: 200, 95 + easing: Easing.in(Easing.cubic), 96 + }); 97 + opacity.value = withTiming(0, { 98 + duration: 200, 99 + }); 100 + } 101 + }, [isExiting, position]); 102 + 103 + const animatedStyle = useAnimatedStyle(() => ({ 104 + transform: [{ translateY: translateY.value }], 105 + opacity: opacity.value, 106 + })); 107 + 108 + const variantStyles = { 109 + default: { 110 + backgroundColor: theme.colors.card, 111 + borderColor: theme.colors.border, 112 + }, 113 + info: { 114 + backgroundColor: theme.colors.info, 115 + borderColor: theme.colors.info, 116 + }, 117 + warning: { 118 + backgroundColor: theme.colors.warning, 119 + borderColor: theme.colors.warning, 120 + }, 121 + }; 122 + 123 + const handleDismiss = (reason: "user" | "auto" = "user") => { 124 + console.log("Dismissing notification:", notification.id); 125 + setIsExiting(true); 126 + setTimeout(() => { 127 + console.log("Requesting dismiss for notification:", notification.id); 128 + streamNotificationManager.hide(notification.id, reason); 129 + }, 200); 130 + console.log(streamNotificationManager.getAll()); 131 + }; 132 + 133 + const handleAction = () => { 134 + notification.onAction?.(); 135 + streamNotificationManager.hide(notification.id, "user"); 136 + }; 137 + 138 + const positionStyle = position === "top" ? { top: 0 } : { bottom: 0 }; 139 + 140 + return ( 141 + <Animated.View 142 + style={[ 143 + styles.notification, 144 + positionStyle, 145 + notification.render 146 + ? {} 147 + : variantStyles[notification.variant || "default"], 148 + { margin: 0, padding: 0 }, 149 + animatedStyle, 150 + ]} 151 + > 152 + {notification.render ? ( 153 + notification.render(isExiting, handleDismiss, notification.startTime) 154 + ) : ( 155 + <View style={styles.content}> 156 + <Text style={[styles.message, { color: theme.colors.foreground }]}> 157 + {notification.message} 158 + </Text> 159 + 160 + <View style={styles.actions}> 161 + {notification.actionLabel && ( 162 + <Pressable onPress={handleAction}> 163 + <Text 164 + style={[styles.actionButton, { color: theme.colors.primary }]} 165 + > 166 + {notification.actionLabel} 167 + </Text> 168 + </Pressable> 169 + )} 170 + 171 + <Pressable 172 + onPress={() => handleDismiss("user")} 173 + style={styles.closeButton} 174 + > 175 + <X size={16} color={theme.colors.mutedForeground} /> 176 + </Pressable> 177 + </View> 178 + </View> 179 + )} 180 + </Animated.View> 181 + ); 182 + } 183 + 184 + const styles = StyleSheet.create({ 185 + container: { 186 + flex: 1, 187 + pointerEvents: "box-none", 188 + }, 189 + notification: { 190 + position: "absolute", 191 + top: 0, 192 + left: 16, 193 + right: 16, 194 + zIndex: 9999, 195 + borderRadius: 8, 196 + borderWidth: 1, 197 + padding: 12, 198 + shadowColor: "#000", 199 + shadowOffset: { width: 0, height: 2 }, 200 + shadowOpacity: 0.25, 201 + shadowRadius: 8, 202 + elevation: 5, 203 + }, 204 + content: { 205 + flexDirection: "row", 206 + alignItems: "center", 207 + justifyContent: "space-between", 208 + gap: 12, 209 + }, 210 + message: { 211 + flex: 1, 212 + fontSize: 14, 213 + fontWeight: "500", 214 + }, 215 + actions: { 216 + flexDirection: "row", 217 + alignItems: "center", 218 + gap: 12, 219 + }, 220 + actionButton: { 221 + fontSize: 14, 222 + fontWeight: "600", 223 + }, 224 + closeButton: { 225 + padding: 4, 226 + }, 227 + });
+187
js/components/src/components/stream-notification/teleport-notification.tsx
··· 1 + import { useEffect, useState } from "react"; 2 + import { useWindowDimensions, View } from "react-native"; 3 + import Animated, { 4 + Easing, 5 + useAnimatedStyle, 6 + useSharedValue, 7 + withRepeat, 8 + withTiming, 9 + } from "react-native-reanimated"; 10 + import { Button, Text, useTheme, zero } from "../../"; 11 + 12 + export function TeleportNotification({ 13 + targetHandle, 14 + countdown, 15 + canCancel, 16 + startTime, 17 + onDismiss, 18 + }: { 19 + targetHandle: string; 20 + countdown: number; 21 + canCancel: boolean; 22 + startTime?: number; 23 + onDismiss: (reason?: "user" | "auto") => void; 24 + }) { 25 + const { zero: z } = useTheme(); 26 + const w = useWindowDimensions().width; 27 + 28 + const [start, setStart] = useState(Date.now()); 29 + const [now, setNow] = useState(Date.now()); 30 + const [dismissed, setDismissed] = useState(false); 31 + 32 + useEffect(() => { 33 + const interval = setInterval(() => { 34 + setNow(Date.now()); 35 + }, 100); 36 + return () => clearInterval(interval); 37 + }, []); 38 + 39 + const timeLeft = Math.max(0, countdown - Math.floor((now - start) / 1000)); 40 + 41 + useEffect(() => { 42 + if (dismissed) { 43 + return; 44 + } 45 + if (timeLeft <= 0) { 46 + setDismissed(true); 47 + onDismiss("auto"); 48 + } 49 + }, [dismissed, onDismiss, timeLeft]); 50 + 51 + // if we're past 5 seconds from start, stripes should already be hidden 52 + const elapsedTime = startTime ? (Date.now() - startTime) / 1000 : 0; 53 + const [showStripes, setShowStripes] = useState(elapsedTime < 5); 54 + 55 + const stripeX = useSharedValue(0); 56 + const stripeOpacity = useSharedValue(1); 57 + const progressWidth = useSharedValue(100); 58 + 59 + useEffect(() => { 60 + // if stripes are already hidden, fade out asap and return 61 + if (!showStripes) { 62 + stripeOpacity.value = withTiming(0, { duration: 0 }); 63 + return; 64 + } 65 + // warning stripes animation 66 + stripeX.value = withRepeat( 67 + withTiming(30 * 2, { 68 + duration: 1000, 69 + easing: Easing.linear, 70 + }), 71 + 3, 72 + false, 73 + ); 74 + 75 + // hide stripes after 500ms 76 + const stripesTimer = setTimeout(() => { 77 + // woosh the stripes off to the right before hiding 78 + stripeX.value = withTiming(30 * 80, { 79 + duration: 1500, 80 + easing: Easing.cubic, 81 + }); 82 + // after animation, set stripes as hidden 83 + setTimeout(() => { 84 + setShowStripes(false); 85 + }, 350); 86 + }, 1500); 87 + 88 + return () => clearTimeout(stripesTimer); 89 + }, []); 90 + 91 + useEffect(() => { 92 + if (showStripes) return; 93 + 94 + // animate progress bar 95 + const percentage = (timeLeft / countdown) * 100; 96 + progressWidth.value = withTiming(percentage, { 97 + duration: 1000, 98 + easing: Easing.linear, 99 + }); 100 + }, [timeLeft, countdown, showStripes]); 101 + 102 + const stripesStyle = useAnimatedStyle(() => ({ 103 + opacity: stripeOpacity.value, 104 + transform: [{ translateX: stripeX.value }], 105 + })); 106 + 107 + const progressStyle = useAnimatedStyle(() => ({ 108 + width: `${progressWidth.value}%`, 109 + })); 110 + 111 + return ( 112 + <View style={[{ overflow: "hidden" }, zero.r.lg, zero.bg.neutral[900]]}> 113 + <View 114 + style={[ 115 + zero.layout.flex.row, 116 + zero.layout.flex.alignCenter, 117 + zero.layout.flex.spaceBetween, 118 + zero.px[3], 119 + w > 650 ? zero.py[4] : zero.py[2], 120 + ]} 121 + > 122 + <Text size={w > 650 ? "xl" : "base"}> 123 + Teleporting to @{targetHandle} 124 + </Text> 125 + <View 126 + style={[ 127 + zero.layout.flex.row, 128 + zero.layout.flex.alignCenter, 129 + zero.gap.all[3], 130 + ]} 131 + > 132 + <Text color="muted">{timeLeft}s</Text> 133 + {canCancel && ( 134 + <Button 135 + onPress={() => onDismiss("user")} 136 + width="min" 137 + variant="destructive" 138 + > 139 + Cancel 140 + </Button> 141 + )} 142 + </View> 143 + </View> 144 + <View 145 + style={{ 146 + height: 4, 147 + width: "100%", 148 + borderRadius: 2, 149 + overflow: "hidden", 150 + backgroundColor: "#0f0f1e", 151 + }} 152 + > 153 + <Animated.View 154 + style={[ 155 + { height: "100%", borderRadius: 2, backgroundColor: "#16f4d0" }, 156 + progressStyle, 157 + ]} 158 + /> 159 + </View> 160 + <Animated.View 161 + style={[ 162 + { 163 + position: "absolute", 164 + flexDirection: "row", 165 + height: 180, 166 + width: "200%", 167 + //clickthrough 168 + pointerEvents: "none", 169 + }, 170 + stripesStyle, 171 + ]} 172 + > 173 + {[...Array(80)].map((_, i) => ( 174 + <View 175 + key={i} 176 + style={{ 177 + width: 30, 178 + height: "100%", 179 + backgroundColor: i % 2 === 0 ? "#FFA500" : "#000000", 180 + transform: [{ skewX: "-45deg" }, { translateX: -30 * 8 }], 181 + }} 182 + /> 183 + ))} 184 + </Animated.View> 185 + </View> 186 + ); 187 + }
+88 -34
js/components/src/components/ui/resizeable.tsx
··· 1 1 import { ChevronUp } from "lucide-react-native"; 2 - import { ComponentProps, useEffect } from "react"; 2 + import { ComponentProps, useEffect, useState } from "react"; 3 3 import { Dimensions } from "react-native"; 4 4 import { 5 5 Gesture, ··· 9 9 import Animated, { 10 10 Extrapolation, 11 11 interpolate, 12 + runOnJS, 12 13 useAnimatedStyle, 13 14 useSharedValue, 14 15 withSpring, ··· 27 28 isPlayerRatioGreater: boolean; 28 29 style?: ComponentProps<typeof AnimatedView>["style"]; 29 30 children?: React.ReactNode; 31 + renderAbove?: (isCollapsed: boolean) => React.ReactNode; 30 32 }; 31 33 32 34 const SPRING_CONFIG = { damping: 20, stiffness: 100 }; ··· 36 38 isPlayerRatioGreater, 37 39 style = {}, 38 40 children, 41 + renderAbove, 39 42 }: ResizableChatSheetProps) { 40 43 const { slideKeyboard } = useKeyboardSlide(); 41 44 const { bottom: safeBottom } = useSafeAreaInsets(); ··· 45 48 46 49 const sheetHeight = useSharedValue(MIN_HEIGHT); 47 50 const startHeight = useSharedValue(MIN_HEIGHT); 51 + const [isCollapsed, setIsCollapsed] = useState(true); 52 + const wasCollapsed = useSharedValue(true); 48 53 49 54 useEffect(() => { 50 55 setTimeout(() => { 51 - sheetHeight.value = withSpring( 52 - startingPercentage ? startingPercentage * SCREEN_HEIGHT : MIN_HEIGHT, 53 - SPRING_CONFIG, 54 - ); 56 + const targetHeight = startingPercentage 57 + ? startingPercentage * SCREEN_HEIGHT 58 + : MIN_HEIGHT; 59 + sheetHeight.value = withSpring(targetHeight, SPRING_CONFIG); 60 + setIsCollapsed(targetHeight < COLLAPSE_HEIGHT); 55 61 }, 1000); 56 62 }, []); 57 63 ··· 65 71 if (newHeight < MIN_HEIGHT) newHeight = MIN_HEIGHT; 66 72 sheetHeight.value = newHeight; 67 73 68 - if (newHeight < COLLAPSE_HEIGHT) { 74 + const nowCollapsed = newHeight < COLLAPSE_HEIGHT; 75 + if (nowCollapsed && !wasCollapsed.value) { 69 76 sheetHeight.value = withSpring(MIN_HEIGHT, SPRING_CONFIG); 77 + wasCollapsed.value = true; 78 + runOnJS(setIsCollapsed)(true); 79 + } else if (!nowCollapsed && wasCollapsed.value) { 80 + wasCollapsed.value = false; 81 + runOnJS(setIsCollapsed)(false); 70 82 } 71 83 }); 72 84 ··· 97 109 ], 98 110 })); 99 111 112 + const aboveElementStyle = useAnimatedStyle(() => ({ 113 + // show inside area when not collapsed, and show outside area when collapsed 114 + height: sheetHeight.value < COLLAPSE_HEIGHT ? 0 : sheetHeight.value, 115 + transform: [ 116 + { 117 + translateY: 118 + sheetHeight.value < COLLAPSE_HEIGHT 119 + ? withSpring(-120) 120 + : withSpring(20), 121 + }, 122 + ], 123 + })); 124 + 100 125 return ( 101 126 <> 102 127 <Animated.View ··· 111 136 > 112 137 <Pressable 113 138 onPress={() => { 114 - sheetHeight.value = 115 - sheetHeight.value === MIN_HEIGHT 116 - ? withSpring(MAX_HEIGHT, SPRING_CONFIG) 117 - : withSpring(MIN_HEIGHT, SPRING_CONFIG); 139 + const isCurrentlyCollapsed = sheetHeight.value === MIN_HEIGHT; 140 + sheetHeight.value = isCurrentlyCollapsed 141 + ? withSpring(MAX_HEIGHT, SPRING_CONFIG) 142 + : withSpring(MIN_HEIGHT, SPRING_CONFIG); 143 + setIsCollapsed(!isCurrentlyCollapsed); 118 144 }} 119 145 > 120 146 <View ··· 155 181 ]} 156 182 > 157 183 <View style={[layout.flex.row, layout.flex.justifyCenter, h[2]]}> 158 - <GestureDetector gesture={panGesture}> 159 - <View 160 - // Make the touch area much larger, but keep the visible handle small 161 - style={{ 162 - height: 30, // Large touch area 163 - width: 120, // Wide enough for thumbs 164 - alignItems: "center", 165 - justifyContent: "center", 166 - //backgroundColor: "rgba(0,255,255,0.1)", 167 - transform: [{ translateY: -30 }], 168 - }} 169 - > 184 + <View style={{ alignItems: "center", width: "100%" }}> 185 + <GestureDetector gesture={panGesture}> 170 186 <View 171 - style={[ 172 - w[32], 173 - { 174 - height: 6, 175 - backgroundColor: "#eeeeee66", 176 - borderRadius: 999, 187 + // Make the touch area much larger, but keep the visible handle small 188 + style={{ 189 + height: 30, // Large touch area 190 + width: 120, // Wide enough for thumbs 191 + alignItems: "center", 192 + justifyContent: "center", 193 + //backgroundColor: "rgba(0,255,255,0.1)", 194 + transform: [{ translateY: -30 }], 195 + }} 196 + > 197 + <View 198 + style={[ 199 + w[32], 200 + { 201 + height: 6, 202 + backgroundColor: "#eeeeee66", 203 + borderRadius: 999, 177 204 178 - transform: [{ translateY: 5 }], 179 - }, 180 - ]} 181 - /> 182 - </View> 183 - </GestureDetector> 205 + transform: [{ translateY: 5 }], 206 + }, 207 + ]} 208 + /> 209 + </View> 210 + </GestureDetector> 211 + </View> 184 212 </View> 185 213 186 214 {children} 187 215 </AnimatedView> 216 + <Animated.View 217 + style={[ 218 + aboveElementStyle, 219 + { 220 + width: "100%", 221 + pointerEvents: "none", 222 + position: "absolute", 223 + bottom: 0, 224 + }, 225 + ]} 226 + > 227 + <View 228 + style={{ 229 + pointerEvents: "auto", 230 + width: "100%", 231 + // hate doing it this way, but can't figure out 232 + // how to make it size to content otherwise 233 + minHeight: 50, 234 + height: "100%", 235 + maxHeight: 75, 236 + flex: 0, 237 + }} 238 + > 239 + {renderAbove?.(isCollapsed)} 240 + </View> 241 + </Animated.View> 188 242 </> 189 243 ); 190 244 }
+3
js/components/src/index.tsx
··· 37 37 export { default as VideoRetry } from "./components/mobile-player/video-retry"; 38 38 export * from "./lib/system-messages"; 39 39 40 + export * from "./components/stream-notification"; 41 + export * from "./lib/stream-notifications"; 42 + 40 43 export * from "./utils/format-handle"; 41 44 42 45 export { DanmuOverlay } from "./components/danmu/danmu-overlay";
+65
js/components/src/lib/slash-commands.ts
··· 1 + export interface SlashCommandResult { 2 + handled: boolean; 3 + error?: string; 4 + } 5 + 6 + export type SlashCommandHandler = ( 7 + args: string[], 8 + rawInput: string, 9 + ) => Promise<SlashCommandResult>; 10 + 11 + export interface SlashCommand { 12 + name: string; 13 + description: string; 14 + usage: string; 15 + handler: SlashCommandHandler; 16 + } 17 + 18 + const commands = new Map<string, SlashCommand>(); 19 + 20 + export function registerSlashCommand(command: SlashCommand) { 21 + commands.set(command.name, command); 22 + } 23 + 24 + export function unregisterSlashCommand(name: string) { 25 + commands.delete(name); 26 + } 27 + 28 + export async function handleSlashCommand( 29 + input: string, 30 + ): Promise<SlashCommandResult> { 31 + const trimmed = input.trim(); 32 + if (!trimmed.startsWith("/")) { 33 + return { handled: false }; 34 + } 35 + 36 + const parts = trimmed.slice(1).split(/\s+/); 37 + const commandName = parts[0]?.toLowerCase(); 38 + const args = parts.slice(1); 39 + 40 + if (!commandName) { 41 + return { handled: false }; 42 + } 43 + 44 + const command = commands.get(commandName); 45 + if (!command) { 46 + return { 47 + // for now - return false 48 + handled: false, 49 + error: `Unknown command: /${commandName}`, 50 + }; 51 + } 52 + 53 + try { 54 + return await command.handler(args, trimmed); 55 + } catch (err) { 56 + return { 57 + handled: true, 58 + error: err instanceof Error ? err.message : "Command failed", 59 + }; 60 + } 61 + } 62 + 63 + export function getRegisteredCommands(): SlashCommand[] { 64 + return Array.from(commands.values()); 65 + }
+136
js/components/src/lib/slash-commands/teleport.ts
··· 1 + import { PlaceStreamLiveTeleport, StreamplaceAgent } from "streamplace"; 2 + import { 3 + registerSlashCommand, 4 + SlashCommandHandler, 5 + SlashCommandResult, 6 + } from "../slash-commands"; 7 + 8 + export async function deleteTeleport( 9 + pdsAgent: StreamplaceAgent, 10 + userDID: string, 11 + uri: string, 12 + ) { 13 + const rkey = uri.split("/").pop(); 14 + if (!rkey) { 15 + throw new Error("No rkey found in teleport URI"); 16 + } 17 + return await pdsAgent.com.atproto.repo.deleteRecord({ 18 + repo: userDID, 19 + collection: "place.stream.live.teleport", 20 + rkey: rkey, 21 + }); 22 + } 23 + 24 + export function registerTeleportCommand( 25 + pdsAgent: StreamplaceAgent, 26 + userDID: string, 27 + setActiveTeleportUri?: (uri: string | null) => void, 28 + ) { 29 + const teleportHandler: SlashCommandHandler = async ( 30 + args, 31 + rawInput, 32 + ): Promise<SlashCommandResult> => { 33 + if (args.length === 0) { 34 + return { 35 + handled: true, 36 + error: "Usage: /teleport @handle.bsky.social [duration_seconds]", 37 + }; 38 + } 39 + 40 + let targetHandle = args[0]; 41 + 42 + if (targetHandle.startsWith("@")) { 43 + targetHandle = targetHandle.slice(1); 44 + } 45 + 46 + if (!targetHandle.includes(".")) { 47 + return { 48 + handled: true, 49 + error: "Invalid handle format. Expected: handle.bsky.social", 50 + }; 51 + } 52 + 53 + let countdownSeconds = 10; 54 + if (args.length > 1) { 55 + const parsedDuration = parseInt(args[1], 10); 56 + if (isNaN(parsedDuration)) { 57 + return { 58 + handled: true, 59 + error: "Countdown must be a number (seconds)", 60 + }; 61 + } 62 + if (parsedDuration < 5 || parsedDuration > 300) { 63 + return { 64 + handled: true, 65 + error: "Countdown must be between 5 seconds and 5 minutes", 66 + }; 67 + } 68 + countdownSeconds = parsedDuration; 69 + } 70 + 71 + let targetDID: string; 72 + try { 73 + const resolution = await pdsAgent.resolveHandle({ 74 + handle: targetHandle, 75 + }); 76 + targetDID = resolution.data.did; 77 + } catch (err) { 78 + return { 79 + handled: true, 80 + error: `Could not resolve handle: ${targetHandle}`, 81 + }; 82 + } 83 + 84 + if (targetDID === userDID) { 85 + return { 86 + handled: true, 87 + error: "You cannot teleport to yourself", 88 + }; 89 + } 90 + 91 + const startsAt = new Date( 92 + Date.now() + countdownSeconds * 1000, 93 + ).toISOString(); 94 + 95 + const record: PlaceStreamLiveTeleport.Record = { 96 + $type: "place.stream.live.teleport", 97 + streamer: targetDID, 98 + startsAt, 99 + countdownSeconds, 100 + }; 101 + 102 + try { 103 + const result = await pdsAgent.com.atproto.repo.createRecord({ 104 + repo: userDID, 105 + collection: "place.stream.live.teleport", 106 + record, 107 + }); 108 + 109 + // store the URI in the livestream store 110 + if (setActiveTeleportUri) { 111 + setActiveTeleportUri(result.data.uri); 112 + } 113 + 114 + return { handled: true }; 115 + } catch (err) { 116 + return { 117 + handled: true, 118 + error: err instanceof Error ? err.message : "Failed to create teleport", 119 + }; 120 + } 121 + }; 122 + 123 + registerSlashCommand({ 124 + name: "teleport", 125 + description: "Start a teleport to another streamer", 126 + usage: "/teleport @handle.bsky.social [duration_seconds]", 127 + handler: teleportHandler, 128 + }); 129 + 130 + registerSlashCommand({ 131 + name: "tp", 132 + description: "Start a teleport to another streamer (alias for /teleport)", 133 + usage: "/tp @handle.bsky.social [duration_seconds]", 134 + handler: teleportHandler, 135 + }); 136 + }
+51
js/components/src/lib/stream-notifications.ts
··· 1 + import React from "react"; 2 + import { streamNotification } from "../components/stream-notification"; 3 + import { TeleportNotification } from "../components/stream-notification/teleport-notification"; 4 + 5 + export const StreamNotifications = { 6 + teleport: (params: { 7 + targetHandle: string; 8 + targetDID: string; 9 + countdown: number; 10 + canCancel: boolean; 11 + onDismiss?: (reason?: "user" | "auto") => void; 12 + }) => { 13 + streamNotification.show({ 14 + id: "teleport", 15 + render: (isExiting, onDismiss, startTime) => { 16 + return React.createElement(TeleportNotification, { 17 + targetHandle: params.targetHandle, 18 + countdown: params.countdown, 19 + canCancel: params.canCancel, 20 + startTime: startTime, 21 + onDismiss: onDismiss, 22 + }); 23 + }, 24 + duration: 0, // manually dismissed by countdown or user cancel 25 + variant: "warning", 26 + onDismiss: params.onDismiss, 27 + }); 28 + }, 29 + 30 + teleportCancelled: () => { 31 + streamNotification.hide("teleport"); 32 + }, 33 + 34 + teleportNow: (targetHandle: string) => { 35 + streamNotification.show({ 36 + id: "teleport-now", 37 + message: `Teleporting to @${targetHandle}...`, 38 + duration: 2, 39 + variant: "info", 40 + }); 41 + }, 42 + 43 + activate: (message: string) => { 44 + streamNotification.show({ 45 + id: "stream-activate", 46 + message: message, 47 + duration: 3, 48 + variant: "info", 49 + }); 50 + }, 51 + };
+52 -2
js/components/src/lib/system-messages.ts
··· 4 4 stream_start = "stream_start", 5 5 stream_end = "stream_end", 6 6 notification = "notification", 7 + command_error = "command_error", 7 8 } 8 9 9 10 export interface SystemMessageMetadata { ··· 22 23 * @param metadata Optional metadata for the message 23 24 * @returns A properly formatted ChatMessageViewHydrated object 24 25 */ 26 + let systemMessageCounter = 0; 27 + 25 28 export const createSystemMessage = ( 26 29 type: SystemMessageType, 27 30 text: string, ··· 29 32 date: Date = new Date(), 30 33 ): ChatMessageViewHydrated => { 31 34 const now = date; 35 + const uniqueId = `${now.getTime()}-${systemMessageCounter++}`; 32 36 33 37 return { 34 - uri: `at://did:sys:system/place.stream.chat.message/${now.getTime()}`, 35 - cid: `system-${now.getTime()}`, 38 + uri: `at://did:sys:system/place.stream.chat.message/${uniqueId}`, 39 + cid: `system-${uniqueId}`, 36 40 author: { 37 41 did: "did:sys:system", 38 42 handle: type, // Use handle to specify the type of system message ··· 73 77 { duration }, 74 78 ), 75 79 80 + teleportArrival: ( 81 + streamerName: string, 82 + streamerDid: string, 83 + count: number, 84 + chatProfile?: any, 85 + ): ChatMessageViewHydrated => { 86 + const text = 87 + count > 0 88 + ? `${count} viewer${count !== 1 ? "s" : ""} teleported from ${streamerName}'s stream! Say hi!` 89 + : `Someone teleported from ${streamerName}'s stream! Say hi!`; 90 + const message = createSystemMessage(SystemMessageType.notification, text, { 91 + streamerName, 92 + count, 93 + }); 94 + 95 + // create a mention facet for the streamer name so it gets colored using existing mention rendering 96 + if (chatProfile && streamerDid) { 97 + const nameStart = text.indexOf(streamerName); 98 + 99 + // encode byte positions 100 + const encoder = new TextEncoder(); 101 + const byteStart = encoder.encode(text.substring(0, nameStart)).length; 102 + const byteEnd = byteStart + encoder.encode(streamerName).length; 103 + 104 + message.record.facets = [ 105 + { 106 + index: { 107 + byteStart, 108 + byteEnd, 109 + }, 110 + features: [ 111 + { 112 + $type: "app.bsky.richtext.facet#mention", 113 + did: streamerDid, 114 + }, 115 + ], 116 + }, 117 + ]; 118 + } 119 + 120 + return message; 121 + }, 122 + 76 123 notification: (message: string): ChatMessageViewHydrated => 77 124 createSystemMessage(SystemMessageType.notification, message), 125 + 126 + commandError: (message: string): ChatMessageViewHydrated => 127 + createSystemMessage(SystemMessageType.command_error, message), 78 128 }; 79 129 80 130 /**
+106 -3
js/components/src/livestream-provider/index.tsx
··· 1 - import React, { useContext, useRef } from "react"; 2 - import { LivestreamContext, makeLivestreamStore } from "../livestream-store"; 1 + import React, { useContext, useEffect, useRef } from "react"; 2 + import { useAvatars } from "../hooks"; 3 + import { deleteTeleport } from "../lib/slash-commands/teleport"; 4 + import { StreamNotifications } from "../lib/stream-notifications"; 5 + import { 6 + LivestreamContext, 7 + makeLivestreamStore, 8 + useLivestreamStore, 9 + } from "../livestream-store"; 10 + import { useDID, usePDSAgent } from "../streamplace-store"; 3 11 import { useLivestreamWebsocket } from "./websocket"; 4 12 5 13 export function LivestreamProvider({ 6 14 children, 7 15 src, 16 + onTeleport, 8 17 ignoreOuterContext = false, 9 18 }: { 10 19 children: React.ReactNode; 11 20 src: string; 21 + onTeleport?: (targetHandle: string, targetDID: string) => void; 12 22 ignoreOuterContext?: boolean; 13 23 }) { 14 24 const context = useContext(LivestreamContext); ··· 24 34 (window as any).livestreamStore = store; 25 35 return ( 26 36 <LivestreamContext.Provider value={{ store: store }}> 27 - <LivestreamPoller src={src}>{children}</LivestreamPoller> 37 + <LivestreamPoller src={src} onTeleport={onTeleport}> 38 + {children} 39 + </LivestreamPoller> 28 40 </LivestreamContext.Provider> 29 41 ); 30 42 } ··· 34 46 return <></>; 35 47 } 36 48 49 + export function TeleportWatcher({ 50 + onTeleport, 51 + }: { 52 + onTeleport?: (targetHandle: string, targetDID: string) => void; 53 + }) { 54 + const activeTeleport = useLivestreamStore((state) => state.activeTeleport); 55 + const activeTeleportUri = useLivestreamStore( 56 + (state) => state.activeTeleportUri, 57 + ); 58 + const profile = useAvatars(activeTeleport ? [activeTeleport.streamer] : []); 59 + const livestreamProfile = useLivestreamStore((state) => state.profile); 60 + const pdsAgent = usePDSAgent(); 61 + const userDID = useDID(); 62 + const prevActiveTeleportRef = useRef(activeTeleport); 63 + 64 + useEffect(() => { 65 + if (!activeTeleport || !profile[activeTeleport.streamer]) return; 66 + 67 + const startsAt = new Date(activeTeleport.startsAt); 68 + const now = new Date(); 69 + const countdown = Math.max( 70 + 0, 71 + Math.floor((startsAt.getTime() - now.getTime()) / 1000), 72 + ); 73 + 74 + // resolve the DID to a handle for display 75 + const targetHandle = 76 + profile[activeTeleport.streamer]?.handle || activeTeleport.streamer; 77 + 78 + // check if the current user is the streamer of the current livestream 79 + const canCancel = livestreamProfile?.did === userDID; 80 + 81 + StreamNotifications.teleport({ 82 + targetHandle: targetHandle, 83 + targetDID: activeTeleport.streamer, 84 + countdown: countdown, 85 + canCancel: canCancel, 86 + onDismiss: async (reason) => { 87 + console.log( 88 + "🔍 StreamNotifications.onDismiss called with reason:", 89 + reason, 90 + ); 91 + if (reason === "user" && activeTeleportUri && pdsAgent && userDID) { 92 + try { 93 + await deleteTeleport(pdsAgent, userDID, activeTeleportUri); 94 + } catch (err) { 95 + console.error("Failed to delete teleport:", err); 96 + } 97 + } 98 + if (reason === "auto" && onTeleport) { 99 + console.log( 100 + "🔍 Calling onTeleport with:", 101 + targetHandle, 102 + activeTeleport.streamer, 103 + ); 104 + onTeleport(targetHandle, activeTeleport.streamer); 105 + } else if (reason === "auto" && !onTeleport) { 106 + console.log("🔍 onTeleport is not defined!"); 107 + } else if (reason === "auto") { 108 + console.log( 109 + "🔍 Reason is auto but teleport function not called for unknown reason", 110 + ); 111 + } 112 + }, 113 + }); 114 + }, [ 115 + activeTeleport, 116 + activeTeleportUri, 117 + profile, 118 + onTeleport, 119 + pdsAgent, 120 + userDID, 121 + ]); 122 + 123 + useEffect(() => { 124 + if ( 125 + prevActiveTeleportRef.current && 126 + !activeTeleport && 127 + !activeTeleportUri 128 + ) { 129 + StreamNotifications.teleportCancelled(); 130 + } 131 + prevActiveTeleportRef.current = activeTeleport; 132 + }, [activeTeleport, activeTeleportUri]); 133 + 134 + return <></>; 135 + } 136 + 37 137 export function LivestreamPoller({ 38 138 children, 39 139 src, 140 + onTeleport, 40 141 }: { 41 142 children: React.ReactNode; 42 143 src: string; 144 + onTeleport?: (targetHandle: string, targetDID: string) => void; 43 145 }) { 44 146 // Websocket watcher is a sibling instead of a parent to avoid 45 147 // re-rendering when the websocket does stuff 46 148 return ( 47 149 <> 48 150 <WebsocketWatcher src={src} /> 151 + <TeleportWatcher onTeleport={onTeleport} /> 49 152 {children} 50 153 </> 51 154 );
+4
js/components/src/livestream-store/livestream-state.tsx
··· 3 3 ChatMessageViewHydrated, 4 4 LivestreamViewHydrated, 5 5 PlaceStreamDefs, 6 + PlaceStreamLiveTeleport, 6 7 PlaceStreamModerationPermission, 7 8 PlaceStreamSegment, 8 9 } from "streamplace"; ··· 22 23 replyToMessage: ChatMessageViewHydrated | null; 23 24 streamKey: string | null; 24 25 setStreamKey: (key: string | null) => void; 26 + activeTeleport: PlaceStreamLiveTeleport.Record | null; 27 + activeTeleportUri: string | null; 28 + setActiveTeleportUri: (uri: string | null) => void; 25 29 websocketConnected: boolean; 26 30 hasReceivedSegment: boolean; 27 31 moderationPermissions: PlaceStreamModerationPermission.Record[];
+3
js/components/src/livestream-store/livestream-store.tsx
··· 22 22 authors: {}, 23 23 recentSegments: [], 24 24 problems: [], 25 + activeTeleport: null, 26 + activeTeleportUri: null, 27 + setActiveTeleportUri: (uri) => set({ activeTeleportUri: uri }), 25 28 websocketConnected: false, 26 29 hasReceivedSegment: false, 27 30 moderationPermissions: [],
+35 -54
js/components/src/livestream-store/websocket-consumer.tsx
··· 7 7 PlaceStreamChatMessage, 8 8 PlaceStreamDefs, 9 9 PlaceStreamLivestream, 10 - PlaceStreamModerationPermission, 10 + PlaceStreamLiveTeleport, 11 11 PlaceStreamSegment, 12 12 } from "streamplace"; 13 13 import { SystemMessages } from "../lib/system-messages"; 14 + import { formatHandleWithAt } from "../utils/format-handle"; 14 15 import { reduceChat } from "./chat"; 15 16 import { LivestreamState } from "./livestream-state"; 16 17 import { findProblems } from "./problems"; ··· 121 122 pendingHides: newPendingHides, 122 123 }; 123 124 state = reduceChat(state, [], [], [hiddenMessageUri]); 124 - } else if ( 125 - PlaceStreamModerationPermission.isRecord(message) || 126 - (message && 127 - typeof message === "object" && 128 - "$type" in message && 129 - (message as { $type?: string }).$type === 130 - "place.stream.moderation.permission") 131 - ) { 132 - // Handle moderation permission record updates 133 - // This can be a new permission or a deletion marker 134 - const permRecord = message as 135 - | PlaceStreamModerationPermission.Record 136 - | { deleted?: boolean; rkey?: string; streamer?: string }; 125 + } else if (PlaceStreamLiveTeleport.isRecord(message)) { 126 + const teleportRecord = message as PlaceStreamLiveTeleport.Record; 127 + state = { 128 + ...state, 129 + activeTeleport: teleportRecord, 130 + }; 131 + } else if (PlaceStreamLivestream.isTeleportArrival(message)) { 132 + // teleport has succeeded, we are now at the target stream 133 + const arrival = message as PlaceStreamLivestream.TeleportArrival; 137 134 138 - if ((permRecord as any).deleted) { 139 - // Handle deletion: clear permissions to trigger refetch 140 - // The useCanModerate hook will refetch and repopulate 135 + // add the teleporter's chat profile to the authors cache FIRST so mention rendering works 136 + if (arrival.chatProfile && arrival.source.did) { 141 137 state = { 142 138 ...state, 143 - moderationPermissions: [], 139 + authors: { 140 + ...state.authors, 141 + [arrival.source.did]: arrival.chatProfile, 142 + }, 144 143 }; 145 - } else { 146 - // Handle new/updated permission: add or update in the list 147 - // Use createdAt as a unique identifier since multiple records can exist for the same moderator 148 - // (e.g., one record with "ban" permission, another with "hide" permission) 149 - // Note: rkey would be ideal but isn't always present in the WebSocket message 150 - const newPerm = 151 - permRecord as PlaceStreamModerationPermission.Record & { 152 - rkey?: string; 153 - }; 154 - const existingIndex = state.moderationPermissions.findIndex((p) => { 155 - const pWithRkey = p as PlaceStreamModerationPermission.Record & { 156 - rkey?: string; 157 - }; 158 - // Prefer matching by rkey if available, fall back to createdAt 159 - if (newPerm.rkey && pWithRkey.rkey) { 160 - return pWithRkey.rkey === newPerm.rkey; 161 - } 162 - return ( 163 - p.moderator === newPerm.moderator && 164 - p.createdAt === newPerm.createdAt 165 - ); 166 - }); 144 + } 167 145 168 - let newPermissions: PlaceStreamModerationPermission.Record[]; 169 - if (existingIndex >= 0) { 170 - // Update existing record with same moderator AND createdAt 171 - newPermissions = [...state.moderationPermissions]; 172 - newPermissions[existingIndex] = newPerm; 173 - } else { 174 - // Add new record (could be a new record for an existing moderator with different permissions) 175 - newPermissions = [...state.moderationPermissions, newPerm]; 176 - } 146 + const systemMessage = SystemMessages.teleportArrival( 147 + formatHandleWithAt(arrival.source), 148 + arrival.source.did, 149 + arrival.viewerCount, 150 + arrival.chatProfile, 151 + ); 152 + // set proper times 153 + systemMessage.indexedAt = arrival.startsAt; 154 + systemMessage.record.createdAt = arrival.startsAt; 177 155 178 - state = { 179 - ...state, 180 - moderationPermissions: newPermissions, 181 - }; 182 - } 156 + state = reduceChat(state, [systemMessage], []); 157 + } else if (PlaceStreamLivestream.isTeleportCanceled(message)) { 158 + // teleport was canceled (deleted or denied) 159 + state = { 160 + ...state, 161 + activeTeleport: null, 162 + activeTeleportUri: null, 163 + }; 183 164 } 184 165 } 185 166 }
+1
js/components/src/streamplace-store/index.tsx
··· 5 5 export * from "./stream"; 6 6 export * from "./streamplace-store"; 7 7 export * from "./user"; 8 + export * from "./xrpc";
+1 -1
js/docs/package.json
··· 1 1 { 2 2 "name": "streamplace-docs", 3 3 "type": "module", 4 - "version": "0.9.0", 4 + "version": "0.9.1", 5 5 "scripts": { 6 6 "dev": "astro dev --host 0.0.0.0 --port 38082", 7 7 "start": "astro dev --host 0.0.0.0 --port 38082",
+99
js/docs/src/content/docs/lex-reference/live/place-stream-live-denyteleport.md
··· 1 + --- 2 + title: place.stream.live.denyTeleport 3 + description: Reference for the place.stream.live.denyTeleport lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="main"></a> 11 + 12 + ### `main` 13 + 14 + **Type:** `procedure` 15 + 16 + Deny an incoming teleport request. 17 + 18 + **Parameters:** _(None defined)_ 19 + 20 + **Input:** 21 + 22 + - **Encoding:** `application/json` 23 + - **Schema:** 24 + 25 + **Schema Type:** `object` 26 + 27 + | Name | Type | Req'd | Description | Constraints | 28 + | ----- | -------- | ----- | --------------------------------------- | ---------------- | 29 + | `uri` | `string` | ✅ | The URI of the teleport record to deny. | Format: `at-uri` | 30 + 31 + **Output:** 32 + 33 + - **Encoding:** `application/json` 34 + - **Schema:** 35 + 36 + **Schema Type:** `object` 37 + 38 + | Name | Type | Req'd | Description | Constraints | 39 + | --------- | --------- | ----- | --------------------------------------------- | ----------- | 40 + | `success` | `boolean` | ✅ | Whether the teleport was successfully denied. | | 41 + 42 + **Possible Errors:** 43 + 44 + - `TeleportNotFound`: The specified teleport was not found. 45 + - `Unauthorized`: The authenticated user is not the target of this teleport. 46 + 47 + --- 48 + 49 + ## Lexicon Source 50 + 51 + ```json 52 + { 53 + "lexicon": 1, 54 + "id": "place.stream.live.denyTeleport", 55 + "defs": { 56 + "main": { 57 + "type": "procedure", 58 + "description": "Deny an incoming teleport request.", 59 + "input": { 60 + "encoding": "application/json", 61 + "schema": { 62 + "type": "object", 63 + "required": ["uri"], 64 + "properties": { 65 + "uri": { 66 + "type": "string", 67 + "format": "at-uri", 68 + "description": "The URI of the teleport record to deny." 69 + } 70 + } 71 + } 72 + }, 73 + "output": { 74 + "encoding": "application/json", 75 + "schema": { 76 + "type": "object", 77 + "required": ["success"], 78 + "properties": { 79 + "success": { 80 + "type": "boolean", 81 + "description": "Whether the teleport was successfully denied." 82 + } 83 + } 84 + } 85 + }, 86 + "errors": [ 87 + { 88 + "name": "TeleportNotFound", 89 + "description": "The specified teleport was not found." 90 + }, 91 + { 92 + "name": "Unauthorized", 93 + "description": "The authenticated user is not the target of this teleport." 94 + } 95 + ] 96 + } 97 + } 98 + } 99 + ```
+66
js/docs/src/content/docs/lex-reference/live/place-stream-live-teleport.md
··· 1 + --- 2 + title: place.stream.live.teleport 3 + description: Reference for the place.stream.live.teleport 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 defining a 'teleport', that is active during a certain time. 17 + 18 + **Record Key:** `tid` 19 + 20 + **Record Properties:** 21 + 22 + | Name | Type | Req'd | Description | Constraints | 23 + | ----------------- | --------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | 24 + | `streamer` | `string` | ✅ | The DID of the streamer to teleport to. | Format: `did` | 25 + | `startsAt` | `string` | ✅ | The time the teleport becomes active. | Format: `datetime` | 26 + | `durationSeconds` | `integer` | ❌ | The time limit in seconds for the teleport. If not set, the teleport is permanent. Must be at least 60 seconds, and no more than 32,400 seconds (9 hours). | Min: 60<br/>Max: 32400 | 27 + 28 + --- 29 + 30 + ## Lexicon Source 31 + 32 + ```json 33 + { 34 + "lexicon": 1, 35 + "id": "place.stream.live.teleport", 36 + "defs": { 37 + "main": { 38 + "type": "record", 39 + "key": "tid", 40 + "description": "Record defining a 'teleport', that is active during a certain time.", 41 + "record": { 42 + "type": "object", 43 + "required": ["streamer", "startsAt"], 44 + "properties": { 45 + "streamer": { 46 + "type": "string", 47 + "format": "did", 48 + "description": "The DID of the streamer to teleport to." 49 + }, 50 + "startsAt": { 51 + "type": "string", 52 + "format": "datetime", 53 + "description": "The time the teleport becomes active." 54 + }, 55 + "durationSeconds": { 56 + "type": "integer", 57 + "description": "The time limit in seconds for the teleport. If not set, the teleport is permanent. Must be at least 60 seconds, and no more than 32,400 seconds (9 hours).", 58 + "minimum": 60, 59 + "maximum": 32400 60 + } 61 + } 62 + } 63 + } 64 + } 65 + } 66 + ```
+71
js/docs/src/content/docs/lex-reference/openapi.json
··· 1147 1147 } 1148 1148 } 1149 1149 }, 1150 + "/xrpc/place.stream.live.denyTeleport": { 1151 + "post": { 1152 + "summary": "Deny an incoming teleport request.", 1153 + "operationId": "place.stream.live.denyTeleport", 1154 + "tags": ["place.stream.live"], 1155 + "responses": { 1156 + "200": { 1157 + "description": "Success", 1158 + "content": { 1159 + "application/json": { 1160 + "schema": { 1161 + "type": "object", 1162 + "properties": { 1163 + "success": { 1164 + "type": "boolean", 1165 + "description": "Whether the teleport was successfully denied." 1166 + } 1167 + }, 1168 + "required": ["success"] 1169 + } 1170 + } 1171 + } 1172 + }, 1173 + "400": { 1174 + "description": "Bad Request", 1175 + "content": { 1176 + "application/json": { 1177 + "schema": { 1178 + "type": "object", 1179 + "required": ["error", "message"], 1180 + "properties": { 1181 + "error": { 1182 + "type": "string", 1183 + "oneOf": [ 1184 + { 1185 + "const": "TeleportNotFound" 1186 + }, 1187 + { 1188 + "const": "Unauthorized" 1189 + } 1190 + ] 1191 + }, 1192 + "message": { 1193 + "type": "string" 1194 + } 1195 + } 1196 + } 1197 + } 1198 + } 1199 + } 1200 + }, 1201 + "requestBody": { 1202 + "required": true, 1203 + "content": { 1204 + "application/json": { 1205 + "schema": { 1206 + "type": "object", 1207 + "properties": { 1208 + "uri": { 1209 + "type": "string", 1210 + "description": "The URI of the teleport record to deny.", 1211 + "format": "uri" 1212 + } 1213 + }, 1214 + "required": ["uri"] 1215 + } 1216 + } 1217 + } 1218 + } 1219 + } 1220 + }, 1150 1221 "/xrpc/place.stream.live.getLiveUsers": { 1151 1222 "get": { 1152 1223 "summary": "Get a list of livestream segments for a user",
+84 -3
js/docs/src/content/docs/lex-reference/place-stream-livestream.md
··· 79 79 80 80 --- 81 81 82 + <a name="teleportarrival"></a> 83 + 84 + ### `teleportArrival` 85 + 86 + **Type:** `object` 87 + 88 + **Properties:** 89 + 90 + | Name | Type | Req'd | Description | Constraints | 91 + | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | -------------------------------------------------- | ------------------ | 92 + | `teleportUri` | `string` | ✅ | The URI of the teleport record | Format: `at-uri` | 93 + | `source` | [`app.bsky.actor.defs#profileViewBasic`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/actor/defs.json#profileViewBasic) | ✅ | The streamer who is teleporting their viewers here | | 94 + | `chatProfile` | [`place.stream.chat.profile`](/lex-reference/place-stream-chat-profile) | ❌ | The chat profile of the source streamer | | 95 + | `viewerCount` | `integer` | ✅ | How many viewers are arriving from this teleport | | 96 + | `startsAt` | `string` | ✅ | When this teleport started | Format: `datetime` | 97 + 98 + --- 99 + 100 + <a name="teleportcanceled"></a> 101 + 102 + ### `teleportCanceled` 103 + 104 + **Type:** `object` 105 + 106 + **Properties:** 107 + 108 + | Name | Type | Req'd | Description | Constraints | 109 + | ------------- | -------- | ----- | ------------------------------------------------ | ------------------------------------ | 110 + | `teleportUri` | `string` | ✅ | The URI of the teleport record that was canceled | Format: `at-uri` | 111 + | `reason` | `string` | ✅ | Why this teleport was canceled | Enum: `deleted`, `denied`, `expired` | 112 + 113 + --- 114 + 82 115 <a name="streamplaceanything"></a> 83 116 84 117 ### `streamplaceAnything` ··· 87 120 88 121 **Properties:** 89 122 90 - | Name | Type | Req'd | Description | Constraints | 91 - | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ----------- | ----------- | 92 - | `livestream` | Union of:<br/>&nbsp;&nbsp;[`#livestreamView`](#livestreamview)<br/>&nbsp;&nbsp;[`#viewerCount`](#viewercount)<br/>&nbsp;&nbsp;[`place.stream.defs#blockView`](/lex-reference/place-stream-defs#blockview)<br/>&nbsp;&nbsp;[`place.stream.defs#renditions`](/lex-reference/place-stream-defs#renditions)<br/>&nbsp;&nbsp;[`place.stream.defs#rendition`](/lex-reference/place-stream-defs#rendition)<br/>&nbsp;&nbsp;[`place.stream.chat.defs#messageView`](/lex-reference/place-stream-chat-defs#messageview) | ✅ | | | 123 + | Name | Type | Req'd | Description | Constraints | 124 + | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ----------- | ----------- | 125 + | `livestream` | Union of:<br/>&nbsp;&nbsp;[`#livestreamView`](#livestreamview)<br/>&nbsp;&nbsp;[`#viewerCount`](#viewercount)<br/>&nbsp;&nbsp;[`#teleportArrival`](#teleportarrival)<br/>&nbsp;&nbsp;[`#teleportCanceled`](#teleportcanceled)<br/>&nbsp;&nbsp;[`place.stream.defs#blockView`](/lex-reference/place-stream-defs#blockview)<br/>&nbsp;&nbsp;[`place.stream.defs#renditions`](/lex-reference/place-stream-defs#renditions)<br/>&nbsp;&nbsp;[`place.stream.defs#rendition`](/lex-reference/place-stream-defs#rendition)<br/>&nbsp;&nbsp;[`place.stream.chat.defs#messageView`](/lex-reference/place-stream-chat-defs#messageview) | ✅ | | | 93 126 94 127 --- 95 128 ··· 199 232 } 200 233 } 201 234 }, 235 + "teleportArrival": { 236 + "type": "object", 237 + "required": ["teleportUri", "source", "viewerCount", "startsAt"], 238 + "properties": { 239 + "teleportUri": { 240 + "type": "string", 241 + "format": "at-uri", 242 + "description": "The URI of the teleport record" 243 + }, 244 + "source": { 245 + "type": "ref", 246 + "ref": "app.bsky.actor.defs#profileViewBasic", 247 + "description": "The streamer who is teleporting their viewers here" 248 + }, 249 + "chatProfile": { 250 + "type": "ref", 251 + "ref": "place.stream.chat.profile", 252 + "description": "The chat profile of the source streamer" 253 + }, 254 + "viewerCount": { 255 + "type": "integer", 256 + "description": "How many viewers are arriving from this teleport" 257 + }, 258 + "startsAt": { 259 + "type": "string", 260 + "format": "datetime", 261 + "description": "When this teleport started" 262 + } 263 + } 264 + }, 265 + "teleportCanceled": { 266 + "type": "object", 267 + "required": ["teleportUri", "reason"], 268 + "properties": { 269 + "teleportUri": { 270 + "type": "string", 271 + "format": "at-uri", 272 + "description": "The URI of the teleport record that was canceled" 273 + }, 274 + "reason": { 275 + "type": "string", 276 + "enum": ["deleted", "denied", "expired"], 277 + "description": "Why this teleport was canceled" 278 + } 279 + } 280 + }, 202 281 "streamplaceAnything": { 203 282 "type": "object", 204 283 "required": ["livestream"], ··· 208 287 "refs": [ 209 288 "#livestreamView", 210 289 "#viewerCount", 290 + "#teleportArrival", 291 + "#teleportCanceled", 211 292 "place.stream.defs#blockView", 212 293 "place.stream.defs#renditions", 213 294 "place.stream.defs#rendition",
+1 -1
lerna.json
··· 1 1 { 2 2 "$schema": "node_modules/lerna/schemas/lerna-schema.json", 3 - "version": "0.9.0", 3 + "version": "0.9.1", 4 4 "npmClient": "pnpm" 5 5 }
+47
lexicons/place/stream/live/denyTeleport.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.live.denyTeleport", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Deny an incoming teleport request.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["uri"], 13 + "properties": { 14 + "uri": { 15 + "type": "string", 16 + "format": "at-uri", 17 + "description": "The URI of the teleport record to deny." 18 + } 19 + } 20 + } 21 + }, 22 + "output": { 23 + "encoding": "application/json", 24 + "schema": { 25 + "type": "object", 26 + "required": ["success"], 27 + "properties": { 28 + "success": { 29 + "type": "boolean", 30 + "description": "Whether the teleport was successfully denied." 31 + } 32 + } 33 + } 34 + }, 35 + "errors": [ 36 + { 37 + "name": "TeleportNotFound", 38 + "description": "The specified teleport was not found." 39 + }, 40 + { 41 + "name": "Unauthorized", 42 + "description": "The authenticated user is not the target of this teleport." 43 + } 44 + ] 45 + } 46 + } 47 + }
+33
lexicons/place/stream/live/teleport.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.live.teleport", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "description": "Record defining a 'teleport', that is active during a certain time.", 9 + "record": { 10 + "type": "object", 11 + "required": ["streamer", "startsAt"], 12 + "properties": { 13 + "streamer": { 14 + "type": "string", 15 + "format": "did", 16 + "description": "The DID of the streamer to teleport to." 17 + }, 18 + "startsAt": { 19 + "type": "string", 20 + "format": "datetime", 21 + "description": "The time the teleport becomes active." 22 + }, 23 + "durationSeconds": { 24 + "type": "integer", 25 + "description": "The time limit in seconds for the teleport. If not set, the teleport is permanent. Must be at least 60 seconds, and no more than 32,400 seconds (9 hours).", 26 + "minimum": 60, 27 + "maximum": 32400 28 + } 29 + } 30 + } 31 + } 32 + } 33 + }
+48
lexicons/place/stream/livestream.json
··· 88 88 "count": { "type": "integer" } 89 89 } 90 90 }, 91 + "teleportArrival": { 92 + "type": "object", 93 + "required": ["teleportUri", "source", "viewerCount", "startsAt"], 94 + "properties": { 95 + "teleportUri": { 96 + "type": "string", 97 + "format": "at-uri", 98 + "description": "The URI of the teleport record" 99 + }, 100 + "source": { 101 + "type": "ref", 102 + "ref": "app.bsky.actor.defs#profileViewBasic", 103 + "description": "The streamer who is teleporting their viewers here" 104 + }, 105 + "chatProfile": { 106 + "type": "ref", 107 + "ref": "place.stream.chat.profile", 108 + "description": "The chat profile of the source streamer" 109 + }, 110 + "viewerCount": { 111 + "type": "integer", 112 + "description": "How many viewers are arriving from this teleport" 113 + }, 114 + "startsAt": { 115 + "type": "string", 116 + "format": "datetime", 117 + "description": "When this teleport started" 118 + } 119 + } 120 + }, 121 + "teleportCanceled": { 122 + "type": "object", 123 + "required": ["teleportUri", "reason"], 124 + "properties": { 125 + "teleportUri": { 126 + "type": "string", 127 + "format": "at-uri", 128 + "description": "The URI of the teleport record that was canceled" 129 + }, 130 + "reason": { 131 + "type": "string", 132 + "enum": ["deleted", "denied", "expired"], 133 + "description": "Why this teleport was canceled" 134 + } 135 + } 136 + }, 91 137 "streamplaceAnything": { 92 138 "type": "object", 93 139 "required": ["livestream"], ··· 97 143 "refs": [ 98 144 "#livestreamView", 99 145 "#viewerCount", 146 + "#teleportArrival", 147 + "#teleportCanceled", 100 148 "place.stream.defs#blockView", 101 149 "place.stream.defs#renditions", 102 150 "place.stream.defs#rendition",
+38
pkg/api/websocket.go
··· 7 7 "net/http" 8 8 "time" 9 9 10 + bsky "github.com/bluesky-social/indigo/api/bsky" 10 11 "github.com/google/uuid" 11 12 "github.com/gorilla/websocket" 12 13 "github.com/julienschmidt/httprouter" ··· 238 239 } 239 240 for _, message := range messages { 240 241 initialBurst <- message 242 + } 243 + }() 244 + 245 + go func() { 246 + teleports, err := a.Model.GetActiveTeleportsToRepo(repoDID) 247 + if err != nil { 248 + log.Error(ctx, "could not get active teleports", "error", err) 249 + return 250 + } 251 + // just send the latest one if it started <3m ago 252 + if len(teleports) > 0 && teleports[0].StartsAt.After(time.Now().Add(-3*time.Minute)) { 253 + tp := teleports[0] 254 + if tp.Repo == nil { 255 + log.Error(ctx, "teleportee repo is nil", "uri", tp.URI) 256 + } 257 + viewerCount := a.Bus.GetViewerCount(tp.RepoDID) 258 + arrivalMsg := streamplace.Livestream_TeleportArrival{ 259 + LexiconTypeID: "place.stream.livestream#teleportArrival", 260 + TeleportUri: tp.URI, 261 + Source: &bsky.ActorDefs_ProfileViewBasic{ 262 + Did: tp.RepoDID, 263 + Handle: tp.Repo.Handle, 264 + }, 265 + ViewerCount: int64(viewerCount), 266 + StartsAt: tp.StartsAt.Format(time.RFC3339), 267 + } 268 + 269 + // get the source chat profile 270 + chatProfile, err := a.Model.GetChatProfile(ctx, tp.RepoDID) 271 + if err == nil && chatProfile != nil { 272 + spcp, err := chatProfile.ToStreamplaceChatProfile() 273 + if err == nil { 274 + arrivalMsg.ChatProfile = spcp 275 + } 276 + } 277 + 278 + initialBurst <- arrivalMsg 241 279 } 242 280 }() 243 281
+7
pkg/atproto/firehose.go
··· 305 305 atsync.Bus.Publish(msg.StreamerRepoDID, mv) 306 306 } 307 307 308 + if collection.String() == constants.PLACE_STREAM_LIVE_TELEPORT { 309 + err := atsync.Model.DeleteTeleport(ctx, uri) 310 + if err != nil { 311 + log.Error(ctx, "failed to delete teleport", "err", err) 312 + } 313 + } 314 + 308 315 if collection.String() == constants.PLACE_STREAM_MODERATION_PERMISSION { 309 316 log.Debug(ctx, "deleting moderation delegation", "userDID", evt.Repo, "rkey", rkey.String()) 310 317 err := atsync.Model.DeleteModerationDelegation(ctx, rkey.String())
+77
pkg/atproto/sync.go
··· 380 380 task.ChatProfile = spcp 381 381 } 382 382 383 + case *streamplace.LiveTeleport: 384 + if r == nil { 385 + return nil 386 + } 387 + startsAt, err := time.Parse(time.RFC3339, rec.StartsAt) 388 + if err != nil { 389 + log.Error(ctx, "failed to parse startsAt", "err", err) 390 + return nil 391 + } 392 + viewerCount := atsync.Bus.GetViewerCount(userDID) 393 + tp := &model.Teleport{ 394 + CID: cid, 395 + URI: aturi.String(), 396 + StartsAt: startsAt, 397 + DurationSeconds: rec.DurationSeconds, 398 + ViewerCount: int64(viewerCount), 399 + Teleport: recCBOR, 400 + RepoDID: userDID, 401 + TargetDID: rec.Streamer, 402 + } 403 + err = atsync.Model.CreateTeleport(ctx, tp) 404 + if err != nil { 405 + return fmt.Errorf("failed to create teleport: %w", err) 406 + } 407 + go atsync.Bus.Publish(userDID, rec) 408 + 409 + // schedule arrival notification 10 seconds after startsAt 410 + arrivalTime := startsAt.Add(10 * time.Second) 411 + waitDuration := time.Until(arrivalTime) 412 + if waitDuration < 0 { 413 + waitDuration = 0 414 + } 415 + 416 + time.AfterFunc(waitDuration, func() { 417 + // verify teleport still exists 418 + existingTp, err := atsync.Model.GetTeleportByURI(aturi.String()) 419 + if err != nil { 420 + log.Error(ctx, "failed to get teleport by uri", "err", err) 421 + return 422 + } 423 + if existingTp == nil || existingTp.Denied { 424 + log.Debug(ctx, "teleport no longer active, skipping arrival notification", "uri", aturi.String()) 425 + return 426 + } 427 + 428 + // get the source profile 429 + sourceRepo, err := atsync.Model.GetRepo(userDID) 430 + if err != nil { 431 + log.Error(ctx, "failed to get source repo", "err", err) 432 + return 433 + } 434 + 435 + viewerCount := existingTp.ViewerCount 436 + 437 + arrivalMsg := &streamplace.Livestream_TeleportArrival{ 438 + LexiconTypeID: "place.stream.livestream#teleportArrival", 439 + TeleportUri: aturi.String(), 440 + Source: &bsky.ActorDefs_ProfileViewBasic{ 441 + Did: userDID, 442 + Handle: sourceRepo.Handle, 443 + }, 444 + ViewerCount: int64(viewerCount), 445 + StartsAt: rec.StartsAt, 446 + } 447 + 448 + // get the source chat profile 449 + chatProfile, err := atsync.Model.GetChatProfile(ctx, userDID) 450 + if err == nil && chatProfile != nil { 451 + spcp, err := chatProfile.ToStreamplaceChatProfile() 452 + if err == nil { 453 + arrivalMsg.ChatProfile = spcp 454 + } 455 + } 456 + 457 + atsync.Bus.Publish(rec.Streamer, arrivalMsg) 458 + }) 459 + 383 460 case *streamplace.Key: 384 461 log.Debug(ctx, "creating key", "key", rec) 385 462 time, err := aqtime.FromString(rec.CreatedAt)
+1
pkg/constants/constants.go
··· 5 5 var PLACE_STREAM_CHAT_MESSAGE = "place.stream.chat.message" //nolint:all 6 6 var PLACE_STREAM_CHAT_PROFILE = "place.stream.chat.profile" //nolint:all 7 7 var PLACE_STREAM_SERVER_SETTINGS = "place.stream.server.settings" //nolint:all 8 + var PLACE_STREAM_LIVE_TELEPORT = "place.stream.live.teleport" //nolint:all 8 9 var PLACE_STREAM_MODERATION_PERMISSION = "place.stream.moderation.permission" //nolint:all 9 10 var STREAMPLACE_SIGNING_KEY = "signingKey" //nolint:all 10 11 var APP_BSKY_GRAPH_FOLLOW = "app.bsky.graph.follow" //nolint:all
+1
pkg/gen/gen.go
··· 34 34 streamplace.MetadataContentRights{}, 35 35 streamplace.MetadataContentWarnings{}, 36 36 streamplace.ModerationPermission{}, 37 + streamplace.LiveTeleport{}, 37 38 streamplace.LiveRecommendations{}, 38 39 ); err != nil { 39 40 panic(err)
+9
pkg/model/model.go
··· 74 74 GetLivestreamByPostURI(postURI string) (*Livestream, error) 75 75 GetLatestLivestreams(limit int, before *time.Time) ([]Livestream, error) 76 76 77 + CreateTeleport(ctx context.Context, tp *Teleport) error 78 + GetLatestTeleportForRepo(repoDID string) (*Teleport, error) 79 + GetActiveTeleportsForRepo(repoDID string) ([]Teleport, error) 80 + GetActiveTeleportsToRepo(targetDID string) ([]Teleport, error) 81 + GetTeleportByURI(uri string) (*Teleport, error) 82 + DeleteTeleport(ctx context.Context, uri string) error 83 + DenyTeleport(ctx context.Context, uri string) error 84 + 77 85 CreateBlock(ctx context.Context, block *Block) error 78 86 GetBlock(ctx context.Context, rkey string) (*Block, error) 79 87 GetUserBlock(ctx context.Context, userDID, subjectDID string) (*Block, error) ··· 187 195 Label{}, 188 196 BroadcastOrigin{}, 189 197 MetadataConfiguration{}, 198 + Teleport{}, 190 199 ModerationDelegation{}, 191 200 Recommendation{}, 192 201 } {
+115
pkg/model/teleport.go
··· 1 + package model 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "time" 8 + 9 + "gorm.io/gorm" 10 + "gorm.io/gorm/clause" 11 + ) 12 + 13 + type Teleport struct { 14 + URI string `json:"uri" gorm:"primaryKey;column:uri"` 15 + CID string `json:"cid" gorm:"column:cid"` 16 + StartsAt time.Time `json:"startsAt" gorm:"column:starts_at;index:idx_repo_starts,priority:2"` 17 + DurationSeconds *int64 `json:"durationSeconds" gorm:"column:duration_seconds"` 18 + ViewerCount int64 `json:"viewerCount" gorm:"column:viewer_count;default:0"` 19 + Teleport *[]byte `json:"teleport"` 20 + RepoDID string `json:"repoDID" gorm:"column:repo_did;index:idx_repo_starts,priority:1"` 21 + TargetDID string `json:"targetDID" gorm:"column:target_did;index:idx_target_did"` 22 + Denied bool `json:"denied" gorm:"column:denied;default:false"` 23 + Repo *Repo `json:"repo,omitempty" gorm:"foreignKey:DID;references:RepoDID"` 24 + Target *Repo `json:"target,omitempty" gorm:"foreignKey:DID;references:TargetDID"` 25 + } 26 + 27 + func (m *DBModel) CreateTeleport(ctx context.Context, tp *Teleport) error { 28 + return m.DB.Clauses(clause.OnConflict{ 29 + Columns: []clause.Column{{Name: "uri"}}, 30 + DoUpdates: clause.AssignmentColumns([]string{"cid", "starts_at", "duration_seconds", "viewer_count", "teleport", "repo_did", "target_did"}), 31 + }).Create(tp).Error 32 + } 33 + 34 + func (m *DBModel) GetLatestTeleportForRepo(repoDID string) (*Teleport, error) { 35 + var teleport Teleport 36 + err := m.DB. 37 + Preload("Repo"). 38 + Preload("Target"). 39 + Where("repo_did = ?", repoDID). 40 + Order("starts_at DESC"). 41 + First(&teleport).Error 42 + if errors.Is(err, gorm.ErrRecordNotFound) { 43 + return nil, nil 44 + } 45 + if err != nil { 46 + return nil, fmt.Errorf("error retrieving latest teleport: %w", err) 47 + } 48 + return &teleport, nil 49 + } 50 + 51 + func (m *DBModel) GetActiveTeleportsForRepo(repoDID string) ([]Teleport, error) { 52 + now := time.Now() 53 + var teleports []Teleport 54 + err := m.DB. 55 + Preload("Repo"). 56 + Preload("Target"). 57 + Where("repo_did = ?", repoDID). 58 + Where("denied = ?", false). 59 + Where("starts_at <= ?", now). 60 + Where("(duration_seconds IS NULL OR DATE_ADD(starts_at, INTERVAL duration_seconds SECOND) > ?)", now). 61 + Order("starts_at DESC"). 62 + Find(&teleports).Error 63 + if errors.Is(err, gorm.ErrRecordNotFound) { 64 + return nil, nil 65 + } 66 + if err != nil { 67 + return nil, fmt.Errorf("error retrieving active teleports: %w", err) 68 + } 69 + return teleports, nil 70 + } 71 + 72 + func (m *DBModel) GetActiveTeleportsToRepo(targetDID string) ([]Teleport, error) { 73 + now := time.Now() 74 + var teleports []Teleport 75 + err := m.DB. 76 + Preload("Repo"). 77 + Preload("Target"). 78 + Where("target_did = ?", targetDID). 79 + Where("denied = ?", false). 80 + Where("starts_at <= ?", now). 81 + Where("(duration_seconds IS NULL OR datetime(starts_at, '+' || duration_seconds || ' seconds') > ?)", now). 82 + Order("starts_at DESC"). 83 + Find(&teleports).Error 84 + if errors.Is(err, gorm.ErrRecordNotFound) { 85 + return nil, nil 86 + } 87 + if err != nil { 88 + return nil, fmt.Errorf("error retrieving active teleports to repo: %w", err) 89 + } 90 + return teleports, nil 91 + } 92 + 93 + func (m *DBModel) GetTeleportByURI(uri string) (*Teleport, error) { 94 + var teleport Teleport 95 + err := m.DB. 96 + Preload("Repo"). 97 + Preload("Target"). 98 + Where("uri = ?", uri). 99 + First(&teleport).Error 100 + if errors.Is(err, gorm.ErrRecordNotFound) { 101 + return nil, nil 102 + } 103 + if err != nil { 104 + return nil, fmt.Errorf("error retrieving teleport by uri: %w", err) 105 + } 106 + return &teleport, nil 107 + } 108 + 109 + func (m *DBModel) DeleteTeleport(ctx context.Context, uri string) error { 110 + return m.DB.Where("uri = ?", uri).Delete(&Teleport{}).Error 111 + } 112 + 113 + func (m *DBModel) DenyTeleport(ctx context.Context, uri string) error { 114 + return m.DB.Model(&Teleport{}).Where("uri = ?", uri).Update("denied", true).Error 115 + }
+45
pkg/spxrpc/place_stream_live.go
··· 9 9 "github.com/bluesky-social/indigo/lex/util" 10 10 "github.com/gorilla/websocket" 11 11 "github.com/labstack/echo/v4" 12 + "github.com/streamplace/oatproxy/pkg/oatproxy" 12 13 "stream.place/streamplace/pkg/log" 13 14 "stream.place/streamplace/pkg/spid" 14 15 "stream.place/streamplace/pkg/spmetrics" 15 16 16 17 placestreamtypes "stream.place/streamplace/pkg/streamplace" 17 18 ) 19 + 20 + func (s *Server) handlePlaceStreamLiveDenyTeleport(ctx context.Context, input *placestreamtypes.LiveDenyTeleport_Input) (*placestreamtypes.LiveDenyTeleport_Output, error) { 21 + session, _ := oatproxy.GetOAuthSession(ctx) 22 + if session == nil { 23 + return nil, echo.NewHTTPError(http.StatusUnauthorized, "oauth session not found") 24 + } 25 + 26 + if input.Uri == "" { 27 + return nil, echo.NewHTTPError(http.StatusBadRequest, "URI is required") 28 + } 29 + 30 + teleport, err := s.model.GetTeleportByURI(input.Uri) 31 + if err != nil { 32 + log.Error(ctx, "failed to get teleport", "err", err) 33 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to retrieve teleport") 34 + } 35 + 36 + if teleport == nil { 37 + return nil, echo.NewHTTPError(http.StatusNotFound, "Teleport not found") 38 + } 39 + 40 + if teleport.TargetDID != session.DID { 41 + return nil, echo.NewHTTPError(http.StatusForbidden, "You are not the target of this teleport") 42 + } 43 + 44 + err = s.model.DenyTeleport(ctx, input.Uri) 45 + if err != nil { 46 + log.Error(ctx, "failed to deny teleport", "err", err) 47 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to deny teleport") 48 + } 49 + 50 + cancelMsg := &placestreamtypes.Livestream_TeleportCanceled{ 51 + LexiconTypeID: "place.stream.livestream#teleportCanceled", 52 + TeleportUri: input.Uri, 53 + Reason: "denied", 54 + } 55 + 56 + s.bus.Publish(teleport.RepoDID, cancelMsg) 57 + s.bus.Publish(teleport.TargetDID, cancelMsg) 58 + 59 + return &placestreamtypes.LiveDenyTeleport_Output{ 60 + Success: true, 61 + }, nil 62 + } 18 63 19 64 var replicationUpgrader = websocket.Upgrader{ 20 65 ReadBufferSize: 1024,
+19
pkg/spxrpc/stubs.go
··· 268 268 e.POST("/xrpc/place.stream.branding.updateBlob", s.HandlePlaceStreamBrandingUpdateBlob) 269 269 e.GET("/xrpc/place.stream.broadcast.getBroadcaster", s.HandlePlaceStreamBroadcastGetBroadcaster) 270 270 e.GET("/xrpc/place.stream.graph.getFollowingUser", s.HandlePlaceStreamGraphGetFollowingUser) 271 + e.POST("/xrpc/place.stream.live.denyTeleport", s.HandlePlaceStreamLiveDenyTeleport) 271 272 e.GET("/xrpc/place.stream.live.getLiveUsers", s.HandlePlaceStreamLiveGetLiveUsers) 272 273 e.GET("/xrpc/place.stream.live.getProfileCard", s.HandlePlaceStreamLiveGetProfileCard) 273 274 e.GET("/xrpc/place.stream.live.getRecommendations", s.HandlePlaceStreamLiveGetRecommendations) ··· 378 379 var handleErr error 379 380 // func (s *Server) handlePlaceStreamGraphGetFollowingUser(ctx context.Context,subjectDID string,userDID string) (*placestream.GraphGetFollowingUser_Output, error) 380 381 out, handleErr = s.handlePlaceStreamGraphGetFollowingUser(ctx, subjectDID, userDID) 382 + if handleErr != nil { 383 + return handleErr 384 + } 385 + return c.JSON(200, out) 386 + } 387 + 388 + func (s *Server) HandlePlaceStreamLiveDenyTeleport(c echo.Context) error { 389 + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamLiveDenyTeleport") 390 + defer span.End() 391 + 392 + var body placestream.LiveDenyTeleport_Input 393 + if err := c.Bind(&body); err != nil { 394 + return err 395 + } 396 + var out *placestream.LiveDenyTeleport_Output 397 + var handleErr error 398 + // func (s *Server) handlePlaceStreamLiveDenyTeleport(ctx context.Context,body *placestream.LiveDenyTeleport_Input) (*placestream.LiveDenyTeleport_Output, error) 399 + out, handleErr = s.handlePlaceStreamLiveDenyTeleport(ctx, &body) 381 400 if handleErr != nil { 382 401 return handleErr 383 402 }
+237
pkg/streamplace/cbor_gen.go
··· 5526 5526 5527 5527 return nil 5528 5528 } 5529 + func (t *LiveTeleport) MarshalCBOR(w io.Writer) error { 5530 + if t == nil { 5531 + _, err := w.Write(cbg.CborNull) 5532 + return err 5533 + } 5534 + 5535 + cw := cbg.NewCborWriter(w) 5536 + fieldCount := 4 5537 + 5538 + if t.DurationSeconds == nil { 5539 + fieldCount-- 5540 + } 5541 + 5542 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 5543 + return err 5544 + } 5545 + 5546 + // t.LexiconTypeID (string) (string) 5547 + if len("$type") > 1000000 { 5548 + return xerrors.Errorf("Value in field \"$type\" was too long") 5549 + } 5550 + 5551 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 5552 + return err 5553 + } 5554 + if _, err := cw.WriteString(string("$type")); err != nil { 5555 + return err 5556 + } 5557 + 5558 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("place.stream.live.teleport"))); err != nil { 5559 + return err 5560 + } 5561 + if _, err := cw.WriteString(string("place.stream.live.teleport")); err != nil { 5562 + return err 5563 + } 5564 + 5565 + // t.StartsAt (string) (string) 5566 + if len("startsAt") > 1000000 { 5567 + return xerrors.Errorf("Value in field \"startsAt\" was too long") 5568 + } 5569 + 5570 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("startsAt"))); err != nil { 5571 + return err 5572 + } 5573 + if _, err := cw.WriteString(string("startsAt")); err != nil { 5574 + return err 5575 + } 5576 + 5577 + if len(t.StartsAt) > 1000000 { 5578 + return xerrors.Errorf("Value in field t.StartsAt was too long") 5579 + } 5580 + 5581 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.StartsAt))); err != nil { 5582 + return err 5583 + } 5584 + if _, err := cw.WriteString(string(t.StartsAt)); err != nil { 5585 + return err 5586 + } 5587 + 5588 + // t.Streamer (string) (string) 5589 + if len("streamer") > 1000000 { 5590 + return xerrors.Errorf("Value in field \"streamer\" was too long") 5591 + } 5592 + 5593 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("streamer"))); err != nil { 5594 + return err 5595 + } 5596 + if _, err := cw.WriteString(string("streamer")); err != nil { 5597 + return err 5598 + } 5599 + 5600 + if len(t.Streamer) > 1000000 { 5601 + return xerrors.Errorf("Value in field t.Streamer was too long") 5602 + } 5603 + 5604 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Streamer))); err != nil { 5605 + return err 5606 + } 5607 + if _, err := cw.WriteString(string(t.Streamer)); err != nil { 5608 + return err 5609 + } 5610 + 5611 + // t.DurationSeconds (int64) (int64) 5612 + if t.DurationSeconds != nil { 5613 + 5614 + if len("durationSeconds") > 1000000 { 5615 + return xerrors.Errorf("Value in field \"durationSeconds\" was too long") 5616 + } 5617 + 5618 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("durationSeconds"))); err != nil { 5619 + return err 5620 + } 5621 + if _, err := cw.WriteString(string("durationSeconds")); err != nil { 5622 + return err 5623 + } 5624 + 5625 + if t.DurationSeconds == nil { 5626 + if _, err := cw.Write(cbg.CborNull); err != nil { 5627 + return err 5628 + } 5629 + } else { 5630 + if *t.DurationSeconds >= 0 { 5631 + if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.DurationSeconds)); err != nil { 5632 + return err 5633 + } 5634 + } else { 5635 + if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.DurationSeconds-1)); err != nil { 5636 + return err 5637 + } 5638 + } 5639 + } 5640 + 5641 + } 5642 + return nil 5643 + } 5644 + 5645 + func (t *LiveTeleport) UnmarshalCBOR(r io.Reader) (err error) { 5646 + *t = LiveTeleport{} 5647 + 5648 + cr := cbg.NewCborReader(r) 5649 + 5650 + maj, extra, err := cr.ReadHeader() 5651 + if err != nil { 5652 + return err 5653 + } 5654 + defer func() { 5655 + if err == io.EOF { 5656 + err = io.ErrUnexpectedEOF 5657 + } 5658 + }() 5659 + 5660 + if maj != cbg.MajMap { 5661 + return fmt.Errorf("cbor input should be of type map") 5662 + } 5663 + 5664 + if extra > cbg.MaxLength { 5665 + return fmt.Errorf("LiveTeleport: map struct too large (%d)", extra) 5666 + } 5667 + 5668 + n := extra 5669 + 5670 + nameBuf := make([]byte, 15) 5671 + for i := uint64(0); i < n; i++ { 5672 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 5673 + if err != nil { 5674 + return err 5675 + } 5676 + 5677 + if !ok { 5678 + // Field doesn't exist on this type, so ignore it 5679 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 5680 + return err 5681 + } 5682 + continue 5683 + } 5684 + 5685 + switch string(nameBuf[:nameLen]) { 5686 + // t.LexiconTypeID (string) (string) 5687 + case "$type": 5688 + 5689 + { 5690 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 5691 + if err != nil { 5692 + return err 5693 + } 5694 + 5695 + t.LexiconTypeID = string(sval) 5696 + } 5697 + // t.StartsAt (string) (string) 5698 + case "startsAt": 5699 + 5700 + { 5701 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 5702 + if err != nil { 5703 + return err 5704 + } 5705 + 5706 + t.StartsAt = string(sval) 5707 + } 5708 + // t.Streamer (string) (string) 5709 + case "streamer": 5710 + 5711 + { 5712 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 5713 + if err != nil { 5714 + return err 5715 + } 5716 + 5717 + t.Streamer = string(sval) 5718 + } 5719 + // t.DurationSeconds (int64) (int64) 5720 + case "durationSeconds": 5721 + { 5722 + 5723 + b, err := cr.ReadByte() 5724 + if err != nil { 5725 + return err 5726 + } 5727 + if b != cbg.CborNull[0] { 5728 + if err := cr.UnreadByte(); err != nil { 5729 + return err 5730 + } 5731 + maj, extra, err := cr.ReadHeader() 5732 + if err != nil { 5733 + return err 5734 + } 5735 + var extraI int64 5736 + switch maj { 5737 + case cbg.MajUnsignedInt: 5738 + extraI = int64(extra) 5739 + if extraI < 0 { 5740 + return fmt.Errorf("int64 positive overflow") 5741 + } 5742 + case cbg.MajNegativeInt: 5743 + extraI = int64(extra) 5744 + if extraI < 0 { 5745 + return fmt.Errorf("int64 negative overflow") 5746 + } 5747 + extraI = -1 - extraI 5748 + default: 5749 + return fmt.Errorf("wrong type for int64 field: %d", maj) 5750 + } 5751 + 5752 + t.DurationSeconds = (*int64)(&extraI) 5753 + } 5754 + } 5755 + 5756 + default: 5757 + // Field doesn't exist on this type, so ignore it 5758 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 5759 + return err 5760 + } 5761 + } 5762 + } 5763 + 5764 + return nil 5765 + } 5529 5766 func (t *LiveRecommendations) MarshalCBOR(w io.Writer) error { 5530 5767 if t == nil { 5531 5768 _, err := w.Write(cbg.CborNull)
+33
pkg/streamplace/livedenyTeleport.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.live.denyTeleport 4 + 5 + package streamplace 6 + 7 + import ( 8 + "context" 9 + 10 + lexutil "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + // LiveDenyTeleport_Input is the input argument to a place.stream.live.denyTeleport call. 14 + type LiveDenyTeleport_Input struct { 15 + // uri: The URI of the teleport record to deny. 16 + Uri string `json:"uri" cborgen:"uri"` 17 + } 18 + 19 + // LiveDenyTeleport_Output is the output of a place.stream.live.denyTeleport call. 20 + type LiveDenyTeleport_Output struct { 21 + // success: Whether the teleport was successfully denied. 22 + Success bool `json:"success" cborgen:"success"` 23 + } 24 + 25 + // LiveDenyTeleport calls the XRPC method "place.stream.live.denyTeleport". 26 + func LiveDenyTeleport(ctx context.Context, c lexutil.LexClient, input *LiveDenyTeleport_Input) (*LiveDenyTeleport_Output, error) { 27 + var out LiveDenyTeleport_Output 28 + if err := c.LexDo(ctx, lexutil.Procedure, "application/json", "place.stream.live.denyTeleport", nil, input, &out); err != nil { 29 + return nil, err 30 + } 31 + 32 + return &out, nil 33 + }
+23
pkg/streamplace/liveteleport.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.live.teleport 4 + 5 + package streamplace 6 + 7 + import ( 8 + lexutil "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + func init() { 12 + lexutil.RegisterType("place.stream.live.teleport", &LiveTeleport{}) 13 + } 14 + 15 + type LiveTeleport struct { 16 + LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.live.teleport"` 17 + // durationSeconds: The time limit in seconds for the teleport. If not set, the teleport is permanent. Must be at least 60 seconds, and no more than 32,400 seconds (9 hours). 18 + DurationSeconds *int64 `json:"durationSeconds,omitempty" cborgen:"durationSeconds,omitempty"` 19 + // startsAt: The time the teleport becomes active. 20 + StartsAt string `json:"startsAt" cborgen:"startsAt"` 21 + // streamer: The DID of the streamer to teleport to. 22 + Streamer string `json:"streamer" cborgen:"streamer"` 23 + }
+46 -6
pkg/streamplace/streamlivestream.go
··· 59 59 } 60 60 61 61 type Livestream_StreamplaceAnything_Livestream struct { 62 - Livestream_LivestreamView *Livestream_LivestreamView 63 - Livestream_ViewerCount *Livestream_ViewerCount 64 - Defs_BlockView *Defs_BlockView 65 - Defs_Renditions *Defs_Renditions 66 - Defs_Rendition *Defs_Rendition 67 - ChatDefs_MessageView *ChatDefs_MessageView 62 + Livestream_LivestreamView *Livestream_LivestreamView 63 + Livestream_ViewerCount *Livestream_ViewerCount 64 + Livestream_TeleportArrival *Livestream_TeleportArrival 65 + Livestream_TeleportCanceled *Livestream_TeleportCanceled 66 + Defs_BlockView *Defs_BlockView 67 + Defs_Renditions *Defs_Renditions 68 + Defs_Rendition *Defs_Rendition 69 + ChatDefs_MessageView *ChatDefs_MessageView 68 70 } 69 71 70 72 func (t *Livestream_StreamplaceAnything_Livestream) MarshalJSON() ([]byte, error) { ··· 75 77 if t.Livestream_ViewerCount != nil { 76 78 t.Livestream_ViewerCount.LexiconTypeID = "place.stream.livestream#viewerCount" 77 79 return json.Marshal(t.Livestream_ViewerCount) 80 + } 81 + if t.Livestream_TeleportArrival != nil { 82 + t.Livestream_TeleportArrival.LexiconTypeID = "place.stream.livestream#teleportArrival" 83 + return json.Marshal(t.Livestream_TeleportArrival) 84 + } 85 + if t.Livestream_TeleportCanceled != nil { 86 + t.Livestream_TeleportCanceled.LexiconTypeID = "place.stream.livestream#teleportCanceled" 87 + return json.Marshal(t.Livestream_TeleportCanceled) 78 88 } 79 89 if t.Defs_BlockView != nil { 80 90 t.Defs_BlockView.LexiconTypeID = "place.stream.defs#blockView" ··· 108 118 case "place.stream.livestream#viewerCount": 109 119 t.Livestream_ViewerCount = new(Livestream_ViewerCount) 110 120 return json.Unmarshal(b, t.Livestream_ViewerCount) 121 + case "place.stream.livestream#teleportArrival": 122 + t.Livestream_TeleportArrival = new(Livestream_TeleportArrival) 123 + return json.Unmarshal(b, t.Livestream_TeleportArrival) 124 + case "place.stream.livestream#teleportCanceled": 125 + t.Livestream_TeleportCanceled = new(Livestream_TeleportCanceled) 126 + return json.Unmarshal(b, t.Livestream_TeleportCanceled) 111 127 case "place.stream.defs#blockView": 112 128 t.Defs_BlockView = new(Defs_BlockView) 113 129 return json.Unmarshal(b, t.Defs_BlockView) ··· 123 139 default: 124 140 return nil 125 141 } 142 + } 143 + 144 + // Livestream_TeleportArrival is a "teleportArrival" in the place.stream.livestream schema. 145 + type Livestream_TeleportArrival struct { 146 + LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.livestream#teleportArrival"` 147 + // chatProfile: The chat profile of the source streamer 148 + ChatProfile *ChatProfile `json:"chatProfile,omitempty" cborgen:"chatProfile,omitempty"` 149 + // source: The streamer who is teleporting their viewers here 150 + Source *appbsky.ActorDefs_ProfileViewBasic `json:"source" cborgen:"source"` 151 + // startsAt: When this teleport started 152 + StartsAt string `json:"startsAt" cborgen:"startsAt"` 153 + // teleportUri: The URI of the teleport record 154 + TeleportUri string `json:"teleportUri" cborgen:"teleportUri"` 155 + // viewerCount: How many viewers are arriving from this teleport 156 + ViewerCount int64 `json:"viewerCount" cborgen:"viewerCount"` 157 + } 158 + 159 + // Livestream_TeleportCanceled is a "teleportCanceled" in the place.stream.livestream schema. 160 + type Livestream_TeleportCanceled struct { 161 + LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.livestream#teleportCanceled"` 162 + // reason: Why this teleport was canceled 163 + Reason string `json:"reason" cborgen:"reason"` 164 + // teleportUri: The URI of the teleport record that was canceled 165 + TeleportUri string `json:"teleportUri" cborgen:"teleportUri"` 126 166 } 127 167 128 168 // Livestream_ViewerCount is a "viewerCount" in the place.stream.livestream schema.