Live video on the AT Protocol

most of it

authored by

Natalie B. and committed by
Eli Mallon
5735aeed 910f4e01

+1096 -235
+9 -3
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, ··· 87 88 animatedSidebarStyle, 88 89 ]} 89 90 > 90 - <View style={{ flex: 1, position: "relative" }}> 91 - <ChatPanel /> 92 - </View> 91 + <StreamNotificationProvider> 92 + <View style={{ flex: 1, position: "relative" }}> 93 + <ChatPanel /> 94 + </View> 95 + </StreamNotificationProvider> 93 96 </Animated.View> 94 97 </> 95 98 ); ··· 110 113 <Resizable 111 114 isPlayerRatioGreater={isPlayerRatioGreater} 112 115 startingPercentage={0.4} 116 + renderAbove={(isCollapsed) => ( 117 + <StreamNotificationProvider position="bottom" /> 118 + )} 113 119 > 114 120 <ChatPanel /> 115 121 </Resizable>
-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 - }
+49 -12
js/components/src/components/chat/chat-box.tsx
··· 15 15 useTheme, 16 16 View, 17 17 } from "../../"; 18 + import { handleSlashCommand } from "../../lib/slash-commands"; 19 + import { registerTeleportCommand } from "../../lib/slash-commands/teleport"; 20 + import { StreamNotifications } from "../../lib/stream-notifications"; 18 21 import { 19 22 borders, 20 23 flex, ··· 28 31 r, 29 32 w, 30 33 } from "../../lib/theme/atoms"; 34 + import { useDID, usePDSAgent } from "../../streamplace-store"; 31 35 import { Textarea } from "../ui/textarea"; 32 36 import { RenderChatMessage } from "./chat-message"; 33 37 import { EmojiData, EmojiSuggestions } from "./emoji-suggestions"; ··· 72 76 const replyTo = useReplyToMessage(); 73 77 const setReplyToMessage = useSetReplyToMessage(); 74 78 const textAreaRef = useRef<TextInput>(null); 79 + 80 + const pdsAgent = usePDSAgent(); 81 + const userDID = useDID(); 82 + 83 + useEffect(() => { 84 + if (pdsAgent && userDID) { 85 + registerTeleportCommand(pdsAgent, userDID); 86 + } 87 + }, [pdsAgent, userDID]); 75 88 76 89 const authors = useMemo(() => { 77 90 if (!chat) return null; ··· 232 245 } 233 246 }; 234 247 235 - const submit = () => { 248 + const submit = async () => { 236 249 if (!message.trim()) return; 250 + 251 + const messageText = message; 237 252 setMessage(""); 238 253 setReplyToMessage(null); 254 + setSubmitting(true); 239 255 240 - setSubmitting(true); 241 - createChatMessage({ 242 - text: message, 243 - reply: replyTo || undefined, 244 - }); 245 - setSubmitting(false); 256 + try { 257 + const result = await handleSlashCommand(messageText); 258 + 259 + if (result.handled) { 260 + if (result.error) { 261 + console.error("Slash command error:", result.error); 262 + } 263 + } else { 264 + createChatMessage({ 265 + text: messageText, 266 + reply: replyTo || undefined, 267 + }); 268 + } 269 + } catch (err) { 270 + console.error("Error submitting message:", err); 271 + } finally { 272 + setSubmitting(false); 273 + } 246 274 247 - // if we press "send" button, we want the same action as pressing "Enter" 248 - // if we're already focused no need to do extra work 249 275 if (textAreaRef.current && !textAreaRef.current.isFocused()) { 250 276 textAreaRef.current.focus(); 251 277 requestAnimationFrame(() => { ··· 454 480 > 455 481 <Button 456 482 variant="secondary" 483 + style={{ borderRadius: 16 }} 484 + onPress={() => { 485 + StreamNotifications.teleport({ 486 + targetHandle: "test.bsky.social", 487 + targetDID: "did:plc:test", 488 + countdown: 30, 489 + onCancel: () => console.log("teleport cancelled"), 490 + }); 491 + }} 492 + > 493 + Test Notification 494 + </Button> 495 + <Button 496 + variant="secondary" 457 497 style={{ borderRadius: 16, maxWidth: 44, aspectRatio: 1 }} 458 498 aria-label="Insert Mention" 459 499 onPress={() => { 460 - // if the last character is not @, add it 461 500 !message.endsWith("@") && setMessage(message + "@"); 462 - // get all the text after the last @ 463 501 const atIndex = message.lastIndexOf("@"); 464 502 const searchText = message.slice(atIndex + 1).toLowerCase(); 465 503 updateSuggestions(searchText); 466 504 setShowSuggestions(true); 467 - // focus the textarea 468 505 textAreaRef.current?.focus(); 469 506 }} 470 507 >
+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";
+139
js/components/src/components/stream-notification/stream-notification-manager.ts
··· 1 + export type NotificationConfig = { 2 + id?: string; 3 + message?: string; 4 + render?: (isExiting: boolean, onDismiss: () => void) => React.ReactNode; 5 + duration?: number; // seconds, 0 = manual dismiss only 6 + actionLabel?: string; 7 + onAction?: () => void; 8 + onDismiss?: () => void; 9 + onUserDismiss?: () => void; 10 + onAutoDismiss?: () => void; 11 + variant?: "default" | "info" | "warning"; 12 + }; 13 + 14 + export type StreamNotification = NotificationConfig & { 15 + id: string; 16 + visible: boolean; 17 + shouldDismiss?: boolean; 18 + dismissReason?: "user" | "auto"; 19 + }; 20 + 21 + type Listener = (notifications: StreamNotification[]) => void; 22 + 23 + class StreamNotificationManager { 24 + private notifications: StreamNotification[] = []; 25 + private listeners: Set<Listener> = new Set(); 26 + private dismissTimers: Map<string, NodeJS.Timeout> = new Map(); 27 + 28 + show(config: NotificationConfig) { 29 + const notification: StreamNotification = { 30 + id: config.id || `notification-${Date.now()}`, 31 + message: config.message, 32 + render: config.render, 33 + duration: config.duration ?? 5, 34 + actionLabel: config.actionLabel, 35 + onAction: config.onAction, 36 + onDismiss: config.onDismiss, 37 + onUserDismiss: config.onUserDismiss, 38 + onAutoDismiss: config.onAutoDismiss, 39 + variant: config.variant ?? "default", 40 + visible: true, 41 + }; 42 + 43 + // if notification with same ID exists, dismiss it first 44 + const existingIndex = this.notifications.findIndex( 45 + (n) => n.id === notification.id, 46 + ); 47 + if (existingIndex !== -1) { 48 + const existingTimer = this.dismissTimers.get(notification.id); 49 + if (existingTimer) { 50 + clearTimeout(existingTimer); 51 + this.dismissTimers.delete(notification.id); 52 + } 53 + this.notifications = this.notifications.filter( 54 + (n) => n.id !== notification.id, 55 + ); 56 + } 57 + 58 + this.notifications = [...this.notifications, notification]; 59 + this.notifyListeners(); 60 + 61 + // auto-dismiss if duration > 0 62 + if (notification.duration && notification.duration > 0) { 63 + const timer = setTimeout(() => { 64 + this.requestDismiss(notification.id, "auto"); 65 + }, notification.duration * 1000); 66 + this.dismissTimers.set(notification.id, timer); 67 + } 68 + } 69 + 70 + requestDismiss(id: string, reason: "user" | "auto" = "user") { 71 + const notification = this.notifications.find((n) => n.id === id); 72 + if (!notification) { 73 + console.log("Notification not found!"); 74 + return; 75 + } 76 + 77 + // mark the notification for dismissal 78 + notification.shouldDismiss = true; 79 + notification.dismissReason = reason; 80 + this.notifyListeners(); 81 + } 82 + 83 + hide(id: string, reason: "user" | "auto" = "user") { 84 + console.log("Hide called with id:", id, "reason:", reason); 85 + console.log( 86 + "Current notifications:", 87 + this.notifications.map((n) => n.id), 88 + ); 89 + const notification = this.notifications.find((n) => n.id === id); 90 + if (!notification) { 91 + console.log("Notification not found!"); 92 + return; 93 + } 94 + 95 + const timer = this.dismissTimers.get(id); 96 + if (timer) { 97 + clearTimeout(timer); 98 + this.dismissTimers.delete(id); 99 + } 100 + 101 + this.notifications = this.notifications.filter((n) => n.id !== id); 102 + console.log( 103 + "Remaining notifications:", 104 + this.notifications.map((n) => n.id), 105 + ); 106 + this.notifyListeners(); 107 + 108 + notification.onDismiss?.(); 109 + if (reason === "user") { 110 + notification.onUserDismiss?.(); 111 + } else { 112 + notification.onAutoDismiss?.(); 113 + } 114 + } 115 + 116 + getAll(): StreamNotification[] { 117 + return this.notifications; 118 + } 119 + 120 + subscribe(listener: Listener) { 121 + this.listeners.add(listener); 122 + return () => { 123 + this.listeners.delete(listener); 124 + }; 125 + } 126 + 127 + private notifyListeners() { 128 + this.listeners.forEach((listener) => { 129 + listener(this.notifications); 130 + }); 131 + } 132 + } 133 + 134 + export const streamNotificationManager = new StreamNotificationManager(); 135 + 136 + export const streamNotification = { 137 + show: (config: NotificationConfig) => streamNotificationManager.show(config), 138 + hide: (id: string) => streamNotificationManager.hide(id), 139 + };
+213
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 + } 78 + }, [notification.shouldDismiss, isExiting]); 79 + 80 + useEffect(() => { 81 + if (isExiting) { 82 + translateY.value = withTiming(position === "top" ? -100 : 100, { 83 + duration: 200, 84 + easing: Easing.in(Easing.cubic), 85 + }); 86 + opacity.value = withTiming(0, { 87 + duration: 200, 88 + }); 89 + } 90 + }, [isExiting, position]); 91 + 92 + const animatedStyle = useAnimatedStyle(() => ({ 93 + transform: [{ translateY: translateY.value }], 94 + opacity: opacity.value, 95 + })); 96 + 97 + const variantStyles = { 98 + default: { 99 + backgroundColor: theme.colors.card, 100 + borderColor: theme.colors.border, 101 + }, 102 + info: { 103 + backgroundColor: theme.colors.info, 104 + borderColor: theme.colors.info, 105 + }, 106 + warning: { 107 + backgroundColor: theme.colors.warning, 108 + borderColor: theme.colors.warning, 109 + }, 110 + }; 111 + 112 + const handleDismiss = () => { 113 + console.log("Dismissing notification:", notification.id); 114 + setIsExiting(true); 115 + setTimeout(() => { 116 + streamNotificationManager.hide( 117 + notification.id, 118 + notification.dismissReason || "user", 119 + ); 120 + }, 200); 121 + }; 122 + 123 + const handleAction = () => { 124 + notification.onAction?.(); 125 + streamNotificationManager.hide(notification.id, "user"); 126 + }; 127 + 128 + const positionStyle = position === "top" ? { top: 0 } : { bottom: 0 }; 129 + 130 + return ( 131 + <Animated.View 132 + style={[ 133 + styles.notification, 134 + positionStyle, 135 + notification.render 136 + ? {} 137 + : variantStyles[notification.variant || "default"], 138 + { margin: 0, padding: 0 }, 139 + animatedStyle, 140 + ]} 141 + > 142 + {notification.render ? ( 143 + notification.render(isExiting, handleDismiss) 144 + ) : ( 145 + <View style={styles.content}> 146 + <Text style={[styles.message, { color: theme.colors.foreground }]}> 147 + {notification.message} 148 + </Text> 149 + 150 + <View style={styles.actions}> 151 + {notification.actionLabel && ( 152 + <Pressable onPress={handleAction}> 153 + <Text 154 + style={[styles.actionButton, { color: theme.colors.primary }]} 155 + > 156 + {notification.actionLabel} 157 + </Text> 158 + </Pressable> 159 + )} 160 + 161 + <Pressable onPress={handleDismiss} style={styles.closeButton}> 162 + <X size={16} color={theme.colors.mutedForeground} /> 163 + </Pressable> 164 + </View> 165 + </View> 166 + )} 167 + </Animated.View> 168 + ); 169 + } 170 + 171 + const styles = StyleSheet.create({ 172 + container: { 173 + flex: 1, 174 + }, 175 + notification: { 176 + position: "absolute", 177 + top: 0, 178 + left: 16, 179 + right: 16, 180 + zIndex: 9999, 181 + borderRadius: 8, 182 + borderWidth: 1, 183 + padding: 12, 184 + shadowColor: "#000", 185 + shadowOffset: { width: 0, height: 2 }, 186 + shadowOpacity: 0.25, 187 + shadowRadius: 8, 188 + elevation: 5, 189 + }, 190 + content: { 191 + flexDirection: "row", 192 + alignItems: "center", 193 + justifyContent: "space-between", 194 + gap: 12, 195 + }, 196 + message: { 197 + flex: 1, 198 + fontSize: 14, 199 + fontWeight: "500", 200 + }, 201 + actions: { 202 + flexDirection: "row", 203 + alignItems: "center", 204 + gap: 12, 205 + }, 206 + actionButton: { 207 + fontSize: 14, 208 + fontWeight: "600", 209 + }, 210 + closeButton: { 211 + padding: 4, 212 + }, 213 + });
+162
js/components/src/components/stream-notification/teleport-notification.tsx
··· 1 + import { useEffect, useState } from "react"; 2 + import { 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 + onDismiss, 16 + }: { 17 + targetHandle: string; 18 + countdown: number; 19 + onDismiss: () => void; 20 + }) { 21 + const { zero: z } = useTheme(); 22 + const [showStripes, setShowStripes] = useState(true); 23 + const [timeLeft, setTimeLeft] = useState(countdown); 24 + 25 + const stripeX = useSharedValue(0); 26 + const stripeOpacity = useSharedValue(1); 27 + const progressWidth = useSharedValue(100); 28 + 29 + useEffect(() => { 30 + // warning stripes animation 31 + stripeX.value = withRepeat( 32 + withTiming(30 * 2, { 33 + duration: 1000, 34 + easing: Easing.linear, 35 + }), 36 + 3, 37 + false, 38 + ); 39 + 40 + // hide stripes after 500ms 41 + const stripesTimer = setTimeout(() => { 42 + // woosh the stripes off to the right before hiding 43 + stripeX.value = withTiming(30 * 80, { 44 + duration: 1500, 45 + easing: Easing.cubic, 46 + }); 47 + // after animation, set stripes as hidden 48 + setTimeout(() => { 49 + setShowStripes(false); 50 + }, 500); 51 + }, 1500); 52 + 53 + return () => clearTimeout(stripesTimer); 54 + }, []); 55 + 56 + useEffect(() => { 57 + if (showStripes) return; 58 + 59 + // countdown timer 60 + const interval = setInterval(() => { 61 + setTimeLeft((prev) => { 62 + if (prev <= 1) { 63 + clearInterval(interval); 64 + 65 + return 0; 66 + } 67 + return prev - 1; 68 + }); 69 + }, 1000); 70 + 71 + return () => clearInterval(interval); 72 + }, [showStripes, onDismiss]); 73 + 74 + useEffect(() => { 75 + if (showStripes) return; 76 + 77 + // animate progress bar 78 + const percentage = (timeLeft / countdown) * 100; 79 + progressWidth.value = withTiming(percentage, { 80 + duration: 1000, 81 + easing: Easing.linear, 82 + }); 83 + }, [timeLeft, countdown, showStripes]); 84 + 85 + const stripesStyle = useAnimatedStyle(() => ({ 86 + opacity: stripeOpacity.value, 87 + transform: [{ translateX: stripeX.value }], 88 + })); 89 + 90 + const progressStyle = useAnimatedStyle(() => ({ 91 + width: `${progressWidth.value}%`, 92 + })); 93 + 94 + return ( 95 + <View style={[{ overflow: "hidden" }, zero.r.lg, zero.bg.neutral[900]]}> 96 + <View 97 + style={[ 98 + zero.layout.flex.row, 99 + zero.layout.flex.alignCenter, 100 + zero.layout.flex.spaceBetween, 101 + zero.px[3], 102 + zero.py[4], 103 + ]} 104 + > 105 + <Text size="xl">Teleporting to @{targetHandle}</Text> 106 + <View 107 + style={[ 108 + zero.layout.flex.row, 109 + zero.layout.flex.alignCenter, 110 + zero.gap.all[3], 111 + ]} 112 + > 113 + <Text color="muted">{timeLeft}s</Text> 114 + <Button onPress={onDismiss} width="min" variant="destructive"> 115 + Cancel 116 + </Button> 117 + </View> 118 + </View> 119 + <View 120 + style={{ 121 + height: 4, 122 + width: "100%", 123 + borderRadius: 2, 124 + overflow: "hidden", 125 + backgroundColor: "#0f0f1e", 126 + }} 127 + > 128 + <Animated.View 129 + style={[ 130 + { height: "100%", borderRadius: 2, backgroundColor: "#16f4d0" }, 131 + progressStyle, 132 + ]} 133 + /> 134 + </View> 135 + <Animated.View 136 + style={[ 137 + { 138 + position: "absolute", 139 + flexDirection: "row", 140 + height: 180, 141 + width: "200%", 142 + //clickthrough 143 + pointerEvents: "none", 144 + }, 145 + stripesStyle, 146 + ]} 147 + > 148 + {[...Array(80)].map((_, i) => ( 149 + <View 150 + key={i} 151 + style={{ 152 + width: 30, 153 + height: "100%", 154 + backgroundColor: i % 2 === 0 ? "#FFA500" : "#000000", 155 + transform: [{ skewX: "-45deg" }, { translateX: -30 * 8 }], 156 + }} 157 + /> 158 + ))} 159 + </Animated.View> 160 + </View> 161 + ); 162 + }
+63 -33
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, ··· 27 27 isPlayerRatioGreater: boolean; 28 28 style?: ComponentProps<typeof AnimatedView>["style"]; 29 29 children?: React.ReactNode; 30 + renderAbove?: (isCollapsed: boolean) => React.ReactNode; 30 31 }; 31 32 32 33 const SPRING_CONFIG = { damping: 20, stiffness: 100 }; ··· 36 37 isPlayerRatioGreater, 37 38 style = {}, 38 39 children, 40 + renderAbove, 39 41 }: ResizableChatSheetProps) { 40 42 const { slideKeyboard } = useKeyboardSlide(); 41 43 const { bottom: safeBottom } = useSafeAreaInsets(); ··· 45 47 46 48 const sheetHeight = useSharedValue(MIN_HEIGHT); 47 49 const startHeight = useSharedValue(MIN_HEIGHT); 50 + const [isCollapsed, setIsCollapsed] = useState(true); 48 51 49 52 useEffect(() => { 50 53 setTimeout(() => { 51 - sheetHeight.value = withSpring( 52 - startingPercentage ? startingPercentage * SCREEN_HEIGHT : MIN_HEIGHT, 53 - SPRING_CONFIG, 54 - ); 54 + const targetHeight = startingPercentage 55 + ? startingPercentage * SCREEN_HEIGHT 56 + : MIN_HEIGHT; 57 + sheetHeight.value = withSpring(targetHeight, SPRING_CONFIG); 58 + setIsCollapsed(targetHeight < COLLAPSE_HEIGHT); 55 59 }, 1000); 56 60 }, []); 57 61 ··· 67 71 68 72 if (newHeight < COLLAPSE_HEIGHT) { 69 73 sheetHeight.value = withSpring(MIN_HEIGHT, SPRING_CONFIG); 74 + setIsCollapsed(true); 75 + } else { 76 + setIsCollapsed(false); 70 77 } 71 78 }); 72 79 ··· 97 104 ], 98 105 })); 99 106 107 + const aboveElementStyle = useAnimatedStyle(() => ({ 108 + // show inside area when not collapsed, and show outside area when collapsed 109 + height: sheetHeight.value < COLLAPSE_HEIGHT ? 0 : sheetHeight.value, 110 + transform: [ 111 + { 112 + translateY: 113 + sheetHeight.value < COLLAPSE_HEIGHT 114 + ? withSpring(-120) 115 + : withSpring(20), 116 + }, 117 + ], 118 + })); 119 + 100 120 return ( 101 121 <> 102 122 <Animated.View ··· 111 131 > 112 132 <Pressable 113 133 onPress={() => { 114 - sheetHeight.value = 115 - sheetHeight.value === MIN_HEIGHT 116 - ? withSpring(MAX_HEIGHT, SPRING_CONFIG) 117 - : withSpring(MIN_HEIGHT, SPRING_CONFIG); 134 + const isCurrentlyCollapsed = sheetHeight.value === MIN_HEIGHT; 135 + sheetHeight.value = isCurrentlyCollapsed 136 + ? withSpring(MAX_HEIGHT, SPRING_CONFIG) 137 + : withSpring(MIN_HEIGHT, SPRING_CONFIG); 138 + setIsCollapsed(!isCurrentlyCollapsed); 118 139 }} 119 140 > 120 141 <View ··· 155 176 ]} 156 177 > 157 178 <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 - > 179 + <View style={{ alignItems: "center", width: "100%" }}> 180 + <GestureDetector gesture={panGesture}> 170 181 <View 171 - style={[ 172 - w[32], 173 - { 174 - height: 6, 175 - backgroundColor: "#eeeeee66", 176 - borderRadius: 999, 182 + // Make the touch area much larger, but keep the visible handle small 183 + style={{ 184 + height: 30, // Large touch area 185 + width: 120, // Wide enough for thumbs 186 + alignItems: "center", 187 + justifyContent: "center", 188 + //backgroundColor: "rgba(0,255,255,0.1)", 189 + transform: [{ translateY: -30 }], 190 + }} 191 + > 192 + <View 193 + style={[ 194 + w[32], 195 + { 196 + height: 6, 197 + backgroundColor: "#eeeeee66", 198 + borderRadius: 999, 177 199 178 - transform: [{ translateY: 5 }], 179 - }, 180 - ]} 181 - /> 182 - </View> 183 - </GestureDetector> 200 + transform: [{ translateY: 5 }], 201 + }, 202 + ]} 203 + /> 204 + </View> 205 + </GestureDetector> 206 + </View> 184 207 </View> 185 208 186 209 {children} 187 210 </AnimatedView> 211 + <Animated.View 212 + style={[aboveElementStyle, { width: "100%", pointerEvents: "none" }]} 213 + > 214 + <View style={{ pointerEvents: "auto" }}> 215 + {renderAbove?.(isCollapsed)} 216 + </View> 217 + </Animated.View> 188 218 </> 189 219 ); 190 220 }
+2
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 + 40 42 export * from "./utils/format-handle"; 41 43 42 44 export { DanmuOverlay } from "./components/danmu/danmu-overlay";
+64
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 + handled: true, 48 + error: `Unknown command: /${commandName}`, 49 + }; 50 + } 51 + 52 + try { 53 + return await command.handler(args, trimmed); 54 + } catch (err) { 55 + return { 56 + handled: true, 57 + error: err instanceof Error ? err.message : "Command failed", 58 + }; 59 + } 60 + } 61 + 62 + export function getRegisteredCommands(): SlashCommand[] { 63 + return Array.from(commands.values()); 64 + }
+91
js/components/src/lib/slash-commands/teleport.ts
··· 1 + import { PlaceStreamLiveTeleport, StreamplaceAgent } from "streamplace"; 2 + import { registerSlashCommand, SlashCommandResult } from "../slash-commands"; 3 + 4 + export function registerTeleportCommand( 5 + pdsAgent: StreamplaceAgent, 6 + userDID: string, 7 + ) { 8 + registerSlashCommand({ 9 + name: "teleport", 10 + description: "Start a teleport to another streamer", 11 + usage: "/teleport @handle.bsky.social [duration_seconds]", 12 + handler: async (args, rawInput): Promise<SlashCommandResult> => { 13 + if (args.length === 0) { 14 + return { 15 + handled: true, 16 + error: "Usage: /teleport @handle.bsky.social [duration_seconds]", 17 + }; 18 + } 19 + 20 + let targetHandle = args[0]; 21 + 22 + if (targetHandle.startsWith("@")) { 23 + targetHandle = targetHandle.slice(1); 24 + } 25 + 26 + if (!targetHandle.includes(".")) { 27 + return { 28 + handled: true, 29 + error: "Invalid handle format. Expected: handle.bsky.social", 30 + }; 31 + } 32 + 33 + let durationSeconds: number | undefined; 34 + if (args.length > 1) { 35 + const parsedDuration = parseInt(args[1], 10); 36 + if (isNaN(parsedDuration)) { 37 + return { 38 + handled: true, 39 + error: "Duration must be a number (seconds)", 40 + }; 41 + } 42 + if (parsedDuration < 60 || parsedDuration > 32400) { 43 + return { 44 + handled: true, 45 + error: 46 + "Duration must be between 60 seconds and 32400 seconds (9 hours)", 47 + }; 48 + } 49 + durationSeconds = parsedDuration; 50 + } 51 + 52 + let targetDID: string; 53 + try { 54 + const resolution = await pdsAgent.resolveHandle({ 55 + handle: targetHandle, 56 + }); 57 + targetDID = resolution.data.did; 58 + } catch (err) { 59 + return { 60 + handled: true, 61 + error: `Could not resolve handle: ${targetHandle}`, 62 + }; 63 + } 64 + 65 + const startsAt = new Date(Date.now() + 30000).toISOString(); 66 + 67 + const record: PlaceStreamLiveTeleport.Record = { 68 + $type: "place.stream.live.teleport", 69 + streamer: targetDID, 70 + startsAt, 71 + ...(durationSeconds ? { durationSeconds } : {}), 72 + }; 73 + 74 + try { 75 + await pdsAgent.com.atproto.repo.createRecord({ 76 + repo: userDID, 77 + collection: "place.stream.live.teleport", 78 + record, 79 + }); 80 + 81 + return { handled: true }; 82 + } catch (err) { 83 + return { 84 + handled: true, 85 + error: 86 + err instanceof Error ? err.message : "Failed to create teleport", 87 + }; 88 + } 89 + }, 90 + }); 91 + }
+43
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 + onCancel?: () => void; 11 + }) => { 12 + streamNotification.show({ 13 + id: "teleport", 14 + render: (isExiting, onDismiss) => { 15 + return React.createElement(TeleportNotification, { 16 + targetHandle: params.targetHandle, 17 + countdown: params.countdown, 18 + onDismiss: () => { 19 + params.onCancel?.(); 20 + onDismiss(); 21 + }, 22 + }); 23 + }, 24 + // allow some extra time for the countdown animation to finish 25 + duration: 30 + 7, 26 + variant: "warning", 27 + onUserDismiss: params.onCancel, 28 + }); 29 + }, 30 + 31 + teleportCancelled: () => { 32 + streamNotification.hide("teleport"); 33 + }, 34 + 35 + teleportNow: (targetHandle: string) => { 36 + streamNotification.show({ 37 + id: "teleport-now", 38 + message: `Teleporting to @${targetHandle}...`, 39 + duration: 2, 40 + variant: "info", 41 + }); 42 + }, 43 + };
+2
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; 25 27 websocketConnected: boolean; 26 28 hasReceivedSegment: boolean; 27 29 moderationPermissions: PlaceStreamModerationPermission.Record[];
+1
js/components/src/livestream-store/livestream-store.tsx
··· 22 22 authors: {}, 23 23 recentSegments: [], 24 24 problems: [], 25 + activeTeleport: null, 25 26 websocketConnected: false, 26 27 hasReceivedSegment: false, 27 28 moderationPermissions: [],
+1 -59
js/components/src/livestream-store/websocket-consumer.tsx
··· 7 7 PlaceStreamChatMessage, 8 8 PlaceStreamDefs, 9 9 PlaceStreamLivestream, 10 + PlaceStreamLiveTeleport, 10 11 PlaceStreamModerationPermission, 11 12 PlaceStreamSegment, 12 13 } from "streamplace"; ··· 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 }; 137 - 138 - if ((permRecord as any).deleted) { 139 - // Handle deletion: clear permissions to trigger refetch 140 - // The useCanModerate hook will refetch and repopulate 141 - state = { 142 - ...state, 143 - moderationPermissions: [], 144 - }; 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 - }); 167 - 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 - } 177 - 178 - state = { 179 - ...state, 180 - moderationPermissions: newPermissions, 181 - }; 182 - } 183 125 } 184 126 } 185 127 }
+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";
+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 + }
+24
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 + tp := &model.Teleport{ 393 + CID: cid, 394 + URI: aturi.String(), 395 + StartsAt: startsAt, 396 + DurationSeconds: rec.DurationSeconds, 397 + Teleport: recCBOR, 398 + RepoDID: userDID, 399 + TargetDID: rec.Streamer, 400 + } 401 + err = atsync.Model.CreateTeleport(ctx, tp) 402 + if err != nil { 403 + return fmt.Errorf("failed to create teleport: %w", err) 404 + } 405 + go atsync.Bus.Publish(userDID, rec) 406 + 383 407 case *streamplace.Key: 384 408 log.Debug(ctx, "creating key", "key", rec) 385 409 time, err := aqtime.FromString(rec.CreatedAt)
+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)
+5
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 + 77 81 CreateBlock(ctx context.Context, block *Block) error 78 82 GetBlock(ctx context.Context, rkey string) (*Block, error) 79 83 GetUserBlock(ctx context.Context, userDID, subjectDID string) (*Block, error) ··· 187 191 Label{}, 188 192 BroadcastOrigin{}, 189 193 MetadataConfiguration{}, 194 + Teleport{}, 190 195 ModerationDelegation{}, 191 196 Recommendation{}, 192 197 } {
+67
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 + Teleport *[]byte `json:"teleport"` 19 + RepoDID string `json:"repoDID" gorm:"column:repo_did;index:idx_repo_starts,priority:1"` 20 + TargetDID string `json:"targetDID" gorm:"column:target_did;index:idx_target_did"` 21 + Repo *Repo `json:"repo,omitempty" gorm:"foreignKey:DID;references:RepoDID"` 22 + Target *Repo `json:"target,omitempty" gorm:"foreignKey:DID;references:TargetDID"` 23 + } 24 + 25 + func (m *DBModel) CreateTeleport(ctx context.Context, tp *Teleport) error { 26 + return m.DB.Clauses(clause.OnConflict{ 27 + Columns: []clause.Column{{Name: "uri"}}, 28 + DoUpdates: clause.AssignmentColumns([]string{"cid", "starts_at", "duration_seconds", "teleport", "repo_did", "target_did"}), 29 + }).Create(tp).Error 30 + } 31 + 32 + func (m *DBModel) GetLatestTeleportForRepo(repoDID string) (*Teleport, error) { 33 + var teleport Teleport 34 + err := m.DB. 35 + Preload("Repo"). 36 + Preload("Target"). 37 + Where("repo_did = ?", repoDID). 38 + Order("starts_at DESC"). 39 + First(&teleport).Error 40 + if errors.Is(err, gorm.ErrRecordNotFound) { 41 + return nil, nil 42 + } 43 + if err != nil { 44 + return nil, fmt.Errorf("error retrieving latest teleport: %w", err) 45 + } 46 + return &teleport, nil 47 + } 48 + 49 + func (m *DBModel) GetActiveTeleportsForRepo(repoDID string) ([]Teleport, error) { 50 + now := time.Now() 51 + var teleports []Teleport 52 + err := m.DB. 53 + Preload("Repo"). 54 + Preload("Target"). 55 + Where("repo_did = ?", repoDID). 56 + Where("starts_at <= ?", now). 57 + Where("(duration_seconds IS NULL OR DATE_ADD(starts_at, INTERVAL duration_seconds SECOND) > ?)", now). 58 + Order("starts_at DESC"). 59 + Find(&teleports).Error 60 + if errors.Is(err, gorm.ErrRecordNotFound) { 61 + return nil, nil 62 + } 63 + if err != nil { 64 + return nil, fmt.Errorf("error retrieving active teleports: %w", err) 65 + } 66 + return teleports, nil 67 + }
+98 -64
pkg/streamplace/cbor_gen.go
··· 5526 5526 5527 5527 return nil 5528 5528 } 5529 - func (t *LiveRecommendations) MarshalCBOR(w io.Writer) error { 5529 + func (t *LiveTeleport) MarshalCBOR(w io.Writer) error { 5530 5530 if t == nil { 5531 5531 _, err := w.Write(cbg.CborNull) 5532 5532 return err 5533 5533 } 5534 5534 5535 5535 cw := cbg.NewCborWriter(w) 5536 + fieldCount := 4 5536 5537 5537 - if _, err := cw.Write([]byte{163}); err != nil { 5538 + if t.DurationSeconds == nil { 5539 + fieldCount-- 5540 + } 5541 + 5542 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 5538 5543 return err 5539 5544 } 5540 5545 ··· 5550 5555 return err 5551 5556 } 5552 5557 5553 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("place.stream.live.recommendations"))); err != nil { 5558 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("place.stream.live.teleport"))); err != nil { 5554 5559 return err 5555 5560 } 5556 - if _, err := cw.WriteString(string("place.stream.live.recommendations")); err != nil { 5561 + if _, err := cw.WriteString(string("place.stream.live.teleport")); err != nil { 5557 5562 return err 5558 5563 } 5559 5564 5560 - // t.CreatedAt (string) (string) 5561 - if len("createdAt") > 1000000 { 5562 - return xerrors.Errorf("Value in field \"createdAt\" was too long") 5565 + // t.StartsAt (string) (string) 5566 + if len("startsAt") > 1000000 { 5567 + return xerrors.Errorf("Value in field \"startsAt\" was too long") 5563 5568 } 5564 5569 5565 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 5570 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("startsAt"))); err != nil { 5566 5571 return err 5567 5572 } 5568 - if _, err := cw.WriteString(string("createdAt")); err != nil { 5573 + if _, err := cw.WriteString(string("startsAt")); err != nil { 5569 5574 return err 5570 5575 } 5571 5576 5572 - if len(t.CreatedAt) > 1000000 { 5573 - return xerrors.Errorf("Value in field t.CreatedAt was too long") 5577 + if len(t.StartsAt) > 1000000 { 5578 + return xerrors.Errorf("Value in field t.StartsAt was too long") 5574 5579 } 5575 5580 5576 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 5581 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.StartsAt))); err != nil { 5577 5582 return err 5578 5583 } 5579 - if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 5584 + if _, err := cw.WriteString(string(t.StartsAt)); err != nil { 5580 5585 return err 5581 5586 } 5582 5587 5583 - // t.Streamers ([]string) (slice) 5584 - if len("streamers") > 1000000 { 5585 - return xerrors.Errorf("Value in field \"streamers\" was too long") 5588 + // t.Streamer (string) (string) 5589 + if len("streamer") > 1000000 { 5590 + return xerrors.Errorf("Value in field \"streamer\" was too long") 5586 5591 } 5587 5592 5588 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("streamers"))); err != nil { 5593 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("streamer"))); err != nil { 5589 5594 return err 5590 5595 } 5591 - if _, err := cw.WriteString(string("streamers")); err != nil { 5596 + if _, err := cw.WriteString(string("streamer")); err != nil { 5592 5597 return err 5593 5598 } 5594 5599 5595 - if len(t.Streamers) > 8192 { 5596 - return xerrors.Errorf("Slice value in field t.Streamers was too long") 5600 + if len(t.Streamer) > 1000000 { 5601 + return xerrors.Errorf("Value in field t.Streamer was too long") 5597 5602 } 5598 5603 5599 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Streamers))); err != nil { 5604 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Streamer))); err != nil { 5600 5605 return err 5601 5606 } 5602 - for _, v := range t.Streamers { 5603 - if len(v) > 1000000 { 5604 - return xerrors.Errorf("Value in field v was too long") 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") 5605 5616 } 5606 5617 5607 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 5618 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("durationSeconds"))); err != nil { 5608 5619 return err 5609 5620 } 5610 - if _, err := cw.WriteString(string(v)); err != nil { 5621 + if _, err := cw.WriteString(string("durationSeconds")); err != nil { 5611 5622 return err 5612 5623 } 5613 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 + 5614 5641 } 5615 5642 return nil 5616 5643 } 5617 5644 5618 - func (t *LiveRecommendations) UnmarshalCBOR(r io.Reader) (err error) { 5619 - *t = LiveRecommendations{} 5645 + func (t *LiveTeleport) UnmarshalCBOR(r io.Reader) (err error) { 5646 + *t = LiveTeleport{} 5620 5647 5621 5648 cr := cbg.NewCborReader(r) 5622 5649 ··· 5635 5662 } 5636 5663 5637 5664 if extra > cbg.MaxLength { 5638 - return fmt.Errorf("LiveRecommendations: map struct too large (%d)", extra) 5665 + return fmt.Errorf("LiveTeleport: map struct too large (%d)", extra) 5639 5666 } 5640 5667 5641 5668 n := extra 5642 5669 5643 - nameBuf := make([]byte, 9) 5670 + nameBuf := make([]byte, 15) 5644 5671 for i := uint64(0); i < n; i++ { 5645 5672 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 5646 5673 if err != nil { ··· 5667 5694 5668 5695 t.LexiconTypeID = string(sval) 5669 5696 } 5670 - // t.CreatedAt (string) (string) 5671 - case "createdAt": 5697 + // t.StartsAt (string) (string) 5698 + case "startsAt": 5672 5699 5673 5700 { 5674 5701 sval, err := cbg.ReadStringWithMax(cr, 1000000) ··· 5676 5703 return err 5677 5704 } 5678 5705 5679 - t.CreatedAt = string(sval) 5706 + t.StartsAt = string(sval) 5680 5707 } 5681 - // t.Streamers ([]string) (slice) 5682 - case "streamers": 5708 + // t.Streamer (string) (string) 5709 + case "streamer": 5683 5710 5684 - maj, extra, err = cr.ReadHeader() 5685 - if err != nil { 5686 - return err 5687 - } 5711 + { 5712 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 5713 + if err != nil { 5714 + return err 5715 + } 5688 5716 5689 - if extra > 8192 { 5690 - return fmt.Errorf("t.Streamers: array too large (%d)", extra) 5717 + t.Streamer = string(sval) 5691 5718 } 5719 + // t.DurationSeconds (int64) (int64) 5720 + case "durationSeconds": 5721 + { 5692 5722 5693 - if maj != cbg.MajArray { 5694 - return fmt.Errorf("expected cbor array") 5695 - } 5696 - 5697 - if extra > 0 { 5698 - t.Streamers = make([]string, extra) 5699 - } 5700 - 5701 - for i := 0; i < int(extra); i++ { 5702 - { 5703 - var maj byte 5704 - var extra uint64 5705 - var err error 5706 - _ = maj 5707 - _ = extra 5708 - _ = err 5709 - 5710 - { 5711 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 5712 - if err != nil { 5713 - return err 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") 5714 5741 } 5715 - 5716 - t.Streamers[i] = string(sval) 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) 5717 5750 } 5718 5751 5752 + t.DurationSeconds = (*int64)(&extraI) 5719 5753 } 5720 5754 } 5721 5755
+23
pkg/streamplace/liveteleport.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package streamplace 4 + 5 + // schema: place.stream.live.teleport 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + func init() { 12 + util.RegisterType("place.stream.live.teleport", &LiveTeleport{}) 13 + } // 14 + // RECORDTYPE: LiveTeleport 15 + type LiveTeleport struct { 16 + LexiconTypeID string `json:"$type,const=place.stream.live.teleport" 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 + }