Live video on the AT Protocol

Compare changes

Choose any two refs to compare.

+425 -3
+39 -3
js/components/src/components/chat/chat-box.tsx
··· 7 7 import { ChatMessageViewHydrated } from "streamplace"; 8 8 import { Button, Loader, Text, toast, useTheme, View } from "../../"; 9 9 import { handleSlashCommand } from "../../lib/slash-commands"; 10 - import { registerTeleportCommand } from "../../lib/slash-commands/teleport"; 10 + import { 11 + createTeleport, 12 + registerTeleportCommand, 13 + } from "../../lib/slash-commands/teleport"; 11 14 import { StreamNotifications } from "../../lib/stream-notifications"; 12 15 import { SystemMessages } from "../../lib/system-messages"; 13 16 import { ··· 36 39 import { RenderChatMessage } from "./chat-message"; 37 40 import { EmojiData, EmojiSuggestions } from "./emoji-suggestions"; 38 41 import { MentionSuggestions } from "./mention-suggestions"; 42 + import { TeleportModal } from "./teleport-modal"; 39 43 40 44 const COOL_EMOJI_LIST = [ 41 45 // @ts-ignore we can iterate through this just fine it seems ··· 68 72 new Map(), 69 73 ); 70 74 const [filteredEmojis, setFilteredEmojis] = useState<any[]>([]); 75 + const [showTeleportModal, setShowTeleportModal] = useState(false); 71 76 const isOverLimit = graphemer.countGraphemes(message) > 300; 72 77 73 78 let linfo = useLivestream(); ··· 88 93 89 94 useEffect(() => { 90 95 if (pdsAgent && userDID) { 91 - registerTeleportCommand(pdsAgent, userDID, setActiveTeleportUri); 96 + registerTeleportCommand(pdsAgent, userDID, setActiveTeleportUri, () => 97 + setShowTeleportModal(true), 98 + ); 92 99 } 93 100 }, [pdsAgent, userDID, setActiveTeleportUri]); 94 101 ··· 105 112 106 113 useEffect(() => { 107 114 if (pdsAgent && linfo?.author?.did && pdsAgent.did === linfo.author.did) { 108 - registerTeleportCommand(pdsAgent, pdsAgent.did, setActiveTeleportUri); 115 + registerTeleportCommand( 116 + pdsAgent, 117 + pdsAgent.did, 118 + setActiveTeleportUri, 119 + () => setShowTeleportModal(true), 120 + ); 109 121 } 110 122 }, [pdsAgent, linfo?.author?.did, setActiveTeleportUri]); 111 123 ··· 119 131 const beforeColon = message.slice(0, message.lastIndexOf(":")); 120 132 setMessage(`${beforeColon}${emoji.skins[0]?.native} `); 121 133 setShowEmojiSuggestions(false); 134 + }; 135 + 136 + const handleTeleportSubmit = async ( 137 + targetHandle: string, 138 + countdownSeconds: number, 139 + ) => { 140 + if (!pdsAgent || !userDID) return; 141 + 142 + const result = await createTeleport( 143 + pdsAgent, 144 + userDID, 145 + targetHandle, 146 + countdownSeconds, 147 + setActiveTeleportUri, 148 + ); 149 + 150 + if (!result.success && result.error) { 151 + SystemMessages.commandError(result.error); 152 + } 122 153 }; 123 154 124 155 const updateSuggestions = (text: string) => { ··· 321 352 322 353 return ( 323 354 <View style={[layout.flex.column, flex.shrink[1], gap.all[2]]}> 355 + <TeleportModal 356 + open={showTeleportModal} 357 + onOpenChange={setShowTeleportModal} 358 + onSubmit={handleTeleportSubmit} 359 + /> 324 360 {replyTo && ( 325 361 <View 326 362 style={[
+310
js/components/src/components/chat/teleport-modal.tsx
··· 1 + import { Check, X } from "lucide-react-native"; 2 + import React, { useEffect, useMemo, useState } from "react"; 3 + import { Image, Pressable, ScrollView, View } from "react-native"; 4 + import { PlaceStreamLivestream } from "streamplace"; 5 + import { useAvatars, zero } from "../.."; 6 + import { useStreamplaceStore } from "../../streamplace-store"; 7 + import { Button, Input, ResponsiveDialog, Text, useTheme } from "../ui"; 8 + 9 + interface TeleportModalProps { 10 + open: boolean; 11 + onOpenChange: (open: boolean) => void; 12 + onSubmit: (targetHandle: string, countdownSeconds: number) => void; 13 + } 14 + 15 + export const TeleportModal: React.FC<TeleportModalProps> = ({ 16 + open, 17 + onOpenChange, 18 + onSubmit, 19 + }) => { 20 + const [searchQuery, setSearchQuery] = useState(""); 21 + const [selectedStream, setSelectedStream] = 22 + useState<PlaceStreamLivestream.LivestreamView | null>(null); 23 + const [countdownSeconds, setCountdownSeconds] = useState("10"); 24 + 25 + const { theme } = useTheme(); 26 + 27 + const liveUsersCache = useStreamplaceStore((state) => state.liveUsers); 28 + const liveUsersLoading = useStreamplaceStore( 29 + (state) => state.liveUsersLoading, 30 + ); 31 + 32 + const [liveUsers, setLiveUsers] = useState(liveUsersCache); 33 + 34 + useEffect(() => { 35 + setLiveUsers(liveUsersCache); 36 + }, [liveUsersCache]); 37 + 38 + const profiles = useAvatars(liveUsers?.map((u) => u.author?.did || "") || []); 39 + 40 + const filteredStreams = useMemo(() => { 41 + if (!liveUsers) return []; 42 + if (!searchQuery.trim()) return liveUsers; 43 + 44 + const query = searchQuery.toLowerCase(); 45 + // filter by handle or stream title 46 + return liveUsers.filter( 47 + (stream) => 48 + stream.author?.handle?.toLowerCase().includes(query) || 49 + stream.record.title?.toString().toLowerCase().includes(query), 50 + ); 51 + }, [liveUsers, searchQuery]); 52 + 53 + const handleCancel = () => { 54 + setSearchQuery(""); 55 + setSelectedStream(null); 56 + setCountdownSeconds("10"); 57 + onOpenChange(false); 58 + }; 59 + 60 + const handleSubmit = () => { 61 + if (!selectedStream?.author?.handle) return; 62 + 63 + const countdown = parseInt(countdownSeconds, 10); 64 + if (isNaN(countdown) || countdown < 5 || countdown > 300) { 65 + return; 66 + } 67 + 68 + onSubmit(selectedStream.author.handle, countdown); 69 + handleCancel(); 70 + }; 71 + 72 + return ( 73 + <ResponsiveDialog 74 + open={open} 75 + onOpenChange={onOpenChange} 76 + showCloseButton={false} 77 + variant="default" 78 + size="xl" 79 + dismissible={false} 80 + > 81 + <View style={[zero.py[2]]}> 82 + <View style={[zero.layout.flex.row, zero.layout.flex.justify.between]}> 83 + <View style={[zero.mb[4], zero.gap.all[1], zero.layout.flex.column]}> 84 + <Text size="2xl">Teleport to another live streamer</Text> 85 + <Text color="muted"> 86 + Select a streamer to teleport your viewers to their stream. 87 + </Text> 88 + </View> 89 + <Pressable onPress={handleCancel} style={[{ padding: 8 }]}> 90 + <X color={theme.colors.mutedForeground} /> 91 + </Pressable> 92 + </View> 93 + <View style={[zero.mb[4]]}> 94 + <Input 95 + value={searchQuery} 96 + onChangeText={setSearchQuery} 97 + placeholder="Search by handle..." 98 + autoCapitalize="none" 99 + autoCorrect={false} 100 + /> 101 + </View> 102 + 103 + {liveUsersLoading && !liveUsers ? ( 104 + <View style={[zero.py[8], { alignItems: "center" }]}> 105 + <Text color="muted">Loading live users...</Text> 106 + </View> 107 + ) : filteredStreams.length === 0 ? ( 108 + <View style={[zero.py[8], { alignItems: "center" }]}> 109 + <Text color="muted"> 110 + {searchQuery 111 + ? "No matching live users found" 112 + : "No live users found"} 113 + </Text> 114 + </View> 115 + ) : ( 116 + <ScrollView style={[{ maxHeight: 400 }]}> 117 + <View 118 + style={[ 119 + { 120 + flexDirection: "row", 121 + flexWrap: "wrap", 122 + gap: 12, 123 + }, 124 + ]} 125 + > 126 + {filteredStreams.map((stream) => { 127 + const isSelected = selectedStream?.uri === stream.uri; 128 + const profile = profiles[stream.author?.did]; 129 + 130 + return ( 131 + <Pressable 132 + key={stream.uri} 133 + onPress={() => setSelectedStream(stream)} 134 + style={[ 135 + { 136 + width: "49.2%", 137 + minWidth: 200, 138 + }, 139 + ]} 140 + > 141 + <View 142 + style={[ 143 + { 144 + backgroundColor: theme.colors.muted, 145 + borderRadius: 12, 146 + overflow: "hidden", 147 + borderWidth: 2, 148 + borderColor: isSelected 149 + ? theme.colors.primary 150 + : "transparent", 151 + }, 152 + ]} 153 + > 154 + <View 155 + style={[ 156 + { 157 + width: "100%", 158 + aspectRatio: 16 / 9, 159 + backgroundColor: theme.colors.card, 160 + position: "relative", 161 + }, 162 + ]} 163 + > 164 + <Image 165 + source={{ 166 + uri: 167 + "/api/playback/" + 168 + stream.author.did + 169 + "/stream.jpg", 170 + }} 171 + style={{ 172 + width: "100%", 173 + height: "100%", 174 + }} 175 + resizeMode="cover" 176 + /> 177 + {isSelected && ( 178 + <View 179 + style={[ 180 + { 181 + position: "absolute", 182 + top: 8, 183 + right: 8, 184 + backgroundColor: theme.colors.primary, 185 + borderRadius: 999, 186 + width: 24, 187 + height: 24, 188 + alignItems: "center", 189 + justifyContent: "center", 190 + boxShadow: "0 2px 4px rgba(0, 0, 0, 0.6)", 191 + }, 192 + ]} 193 + > 194 + <Check size={16} color="white" /> 195 + </View> 196 + )} 197 + {stream.viewerCount && ( 198 + <View 199 + style={[ 200 + { 201 + position: "absolute", 202 + top: 8, 203 + left: 8, 204 + backgroundColor: "rgba(0, 0, 0, 0.75)", 205 + borderRadius: 999, 206 + paddingHorizontal: 8, 207 + paddingVertical: 4, 208 + }, 209 + ]} 210 + > 211 + <Text style={[{ fontSize: 12, color: "white" }]}> 212 + {stream.viewerCount.count} viewer 213 + {stream.viewerCount.count !== 1 ? "s" : ""} 214 + </Text> 215 + </View> 216 + )} 217 + </View> 218 + <View 219 + style={[ 220 + { 221 + padding: 12, 222 + flexDirection: "row", 223 + gap: 8, 224 + alignItems: "center", 225 + }, 226 + ]} 227 + > 228 + <View 229 + style={[ 230 + { 231 + width: 40, 232 + height: 40, 233 + borderRadius: 20, 234 + overflow: "hidden", 235 + backgroundColor: theme.colors.card, 236 + flexShrink: 0, 237 + }, 238 + ]} 239 + > 240 + {profile?.avatar ? ( 241 + <Image 242 + source={{ 243 + uri: profile.avatar, 244 + }} 245 + style={{ width: "100%", height: "100%" }} 246 + resizeMode="cover" 247 + /> 248 + ) : ( 249 + <View 250 + style={{ 251 + width: "100%", 252 + height: "100%", 253 + backgroundColor: theme.colors.muted, 254 + }} 255 + /> 256 + )} 257 + </View> 258 + 259 + {/* Text */} 260 + <View style={[{ flex: 1, minWidth: 0 }]}> 261 + <Text numberOfLines={1} ellipsizeMode="tail"> 262 + {stream.author?.handle} 263 + </Text> 264 + {stream.record.title ? ( 265 + <Text 266 + numberOfLines={1} 267 + ellipsizeMode="tail" 268 + style={[ 269 + { 270 + fontSize: 12, 271 + color: theme.colors.textMuted, 272 + }, 273 + ]} 274 + > 275 + {stream.record.title as any} 276 + </Text> 277 + ) : null} 278 + </View> 279 + </View> 280 + </View> 281 + </Pressable> 282 + ); 283 + })} 284 + </View> 285 + </ScrollView> 286 + )} 287 + </View> 288 + <View 289 + style={[ 290 + zero.mt[8], 291 + zero.layout.flex.row, 292 + zero.layout.flex.justify.end, 293 + zero.gap.all[2], 294 + ]} 295 + > 296 + <Button width="min" variant="secondary" onPress={handleCancel}> 297 + <Text>Cancel</Text> 298 + </Button> 299 + <Button 300 + width="min" 301 + variant="primary" 302 + onPress={handleSubmit} 303 + disabled={!selectedStream} 304 + > 305 + <Text>Teleport</Text> 306 + </Button> 307 + </View> 308 + </ResponsiveDialog> 309 + ); 310 + };
+8
js/components/src/components/ui/dialog.tsx
··· 477 477 478 478 // Size styles 479 479 smContent: { 480 + width: 400, 480 481 minWidth: 300, 482 + maxWidth: 500, 481 483 minHeight: 200, 482 484 }, 483 485 484 486 mdContent: { 487 + width: 500, 485 488 minWidth: 400, 489 + maxWidth: 600, 486 490 minHeight: 300, 487 491 }, 488 492 489 493 lgContent: { 494 + width: 600, 490 495 minWidth: 500, 496 + maxWidth: 800, 491 497 minHeight: 400, 492 498 }, 493 499 494 500 xlContent: { 501 + width: 800, 495 502 minWidth: 600, 503 + maxWidth: 1000, 496 504 minHeight: 500, 497 505 }, 498 506
+68
js/components/src/lib/slash-commands/teleport.ts
··· 21 21 }); 22 22 } 23 23 24 + export async function createTeleport( 25 + pdsAgent: StreamplaceAgent, 26 + userDID: string, 27 + targetHandle: string, 28 + countdownSeconds: number, 29 + setActiveTeleportUri?: (uri: string | null) => void, 30 + ): Promise<{ success: boolean; error?: string }> { 31 + if (countdownSeconds < 5 || countdownSeconds > 300) { 32 + return { 33 + success: false, 34 + error: "Countdown must be between 5 seconds and 5 minutes", 35 + }; 36 + } 37 + 38 + let targetDID: string; 39 + try { 40 + const resolution = await pdsAgent.resolveHandle({ 41 + handle: targetHandle, 42 + }); 43 + targetDID = resolution.data.did; 44 + } catch (err) { 45 + return { 46 + success: false, 47 + error: `Could not resolve handle: ${targetHandle}`, 48 + }; 49 + } 50 + 51 + if (targetDID === userDID) { 52 + return { 53 + success: false, 54 + error: "You cannot teleport to yourself", 55 + }; 56 + } 57 + 58 + const startsAt = new Date(Date.now() + countdownSeconds * 1000).toISOString(); 59 + 60 + const record: PlaceStreamLiveTeleport.Record = { 61 + $type: "place.stream.live.teleport", 62 + streamer: targetDID, 63 + startsAt, 64 + countdownSeconds, 65 + }; 66 + 67 + try { 68 + const result = await pdsAgent.com.atproto.repo.createRecord({ 69 + repo: userDID, 70 + collection: "place.stream.live.teleport", 71 + record, 72 + }); 73 + 74 + if (setActiveTeleportUri) { 75 + setActiveTeleportUri(result.data.uri); 76 + } 77 + 78 + return { success: true }; 79 + } catch (err) { 80 + return { 81 + success: false, 82 + error: err instanceof Error ? err.message : "Failed to create teleport", 83 + }; 84 + } 85 + } 86 + 24 87 export function registerTeleportCommand( 25 88 pdsAgent: StreamplaceAgent, 26 89 userDID: string, 27 90 setActiveTeleportUri?: (uri: string | null) => void, 91 + onOpenModal?: () => void, 28 92 ) { 29 93 const teleportHandler: SlashCommandHandler = async ( 30 94 args, 31 95 rawInput, 32 96 ): Promise<SlashCommandResult> => { 33 97 if (args.length === 0) { 98 + if (onOpenModal) { 99 + onOpenModal(); 100 + return { handled: true }; 101 + } 34 102 return { 35 103 handled: true, 36 104 error: "Usage: /teleport @handle.bsky.social [duration_seconds]",