Live video on the AT Protocol
79
fork

Configure Feed

Select the types of activity you want to include in your feed.

teleport dialog v0

+315 -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={[
+200
js/components/src/components/chat/teleport-modal.tsx
··· 1 + import React, { useEffect, useMemo, useState } from "react"; 2 + import { Image, Pressable, ScrollView, View } from "react-native"; 3 + import { PlaceStreamLivestream } from "streamplace"; 4 + import { useAvatars, zero } from "../.."; 5 + import { useStreamplaceStore } from "../../streamplace-store"; 6 + import { 7 + Button, 8 + DialogFooter, 9 + Input, 10 + MenuGroup, 11 + MenuItem, 12 + ResponsiveDialog, 13 + Text, 14 + useTheme, 15 + } from "../ui"; 16 + 17 + interface TeleportModalProps { 18 + open: boolean; 19 + onOpenChange: (open: boolean) => void; 20 + onSubmit: (targetHandle: string, countdownSeconds: number) => void; 21 + } 22 + 23 + export const TeleportModal: React.FC<TeleportModalProps> = ({ 24 + open, 25 + onOpenChange, 26 + onSubmit, 27 + }) => { 28 + const [searchQuery, setSearchQuery] = useState(""); 29 + const [selectedStream, setSelectedStream] = 30 + useState<PlaceStreamLivestream.LivestreamView | null>(null); 31 + const [countdownSeconds, setCountdownSeconds] = useState("10"); 32 + 33 + const { theme } = useTheme(); 34 + 35 + const liveUsersCache = useStreamplaceStore((state) => state.liveUsers); 36 + const liveUsersLoading = useStreamplaceStore( 37 + (state) => state.liveUsersLoading, 38 + ); 39 + 40 + const [liveUsers, setLiveUsers] = useState(liveUsersCache); 41 + 42 + useEffect(() => { 43 + setLiveUsers(liveUsersCache); 44 + }, [liveUsersCache]); 45 + 46 + const profiles = useAvatars(liveUsers?.map((u) => u.author?.did || "") || []); 47 + 48 + const filteredStreams = useMemo(() => { 49 + if (!liveUsers) return []; 50 + if (!searchQuery.trim()) return liveUsers; 51 + 52 + const query = searchQuery.toLowerCase(); 53 + return liveUsers.filter( 54 + (stream) => 55 + stream.author?.handle?.toLowerCase().includes(query) || 56 + stream.author?.displayName?.toLowerCase().includes(query), 57 + ); 58 + }, [liveUsers, searchQuery]); 59 + 60 + const handleCancel = () => { 61 + setSearchQuery(""); 62 + setSelectedStream(null); 63 + setCountdownSeconds("10"); 64 + onOpenChange(false); 65 + }; 66 + 67 + const handleSubmit = () => { 68 + if (!selectedStream?.author?.handle) return; 69 + 70 + const countdown = parseInt(countdownSeconds, 10); 71 + if (isNaN(countdown) || countdown < 5 || countdown > 300) { 72 + return; 73 + } 74 + 75 + onSubmit(selectedStream.author.handle, countdown); 76 + handleCancel(); 77 + }; 78 + 79 + return ( 80 + <ResponsiveDialog 81 + open={open} 82 + onOpenChange={onOpenChange} 83 + title="Teleport to Streamer" 84 + showCloseButton 85 + variant="default" 86 + size="md" 87 + dismissible={false} 88 + > 89 + <View style={[zero.py[2]]}> 90 + <View style={[zero.mb[4]]}> 91 + <Input 92 + value={searchQuery} 93 + onChangeText={setSearchQuery} 94 + placeholder="Search by handle..." 95 + autoCapitalize="none" 96 + autoCorrect={false} 97 + /> 98 + </View> 99 + 100 + {liveUsersLoading && !liveUsers ? ( 101 + <View style={[zero.py[8], { alignItems: "center" }]}> 102 + <Text style={[{ color: theme.colors.textMuted }]}> 103 + Loading live users... 104 + </Text> 105 + </View> 106 + ) : filteredStreams.length === 0 ? ( 107 + <View style={[zero.py[8], { alignItems: "center" }]}> 108 + <Text style={[{ color: theme.colors.textMuted }]}> 109 + {searchQuery 110 + ? "No matching live users found" 111 + : "No live users found"} 112 + </Text> 113 + </View> 114 + ) : ( 115 + <ScrollView style={[{ maxHeight: 400 }]}> 116 + <MenuGroup> 117 + {filteredStreams.map((stream) => ( 118 + <Pressable 119 + key={stream.uri} 120 + onPress={() => setSelectedStream(stream)} 121 + > 122 + <MenuItem 123 + style={ 124 + [ 125 + selectedStream?.uri === stream.uri && { 126 + backgroundColor: "rgba(0, 122, 255, 0.1)", 127 + }, 128 + zero.layout.flex.spaceBetween, 129 + zero.r.md, 130 + zero.flex[1], 131 + zero.gap.all[2], 132 + { width: "100%" }, 133 + ] as any 134 + } 135 + > 136 + <Image 137 + source={{ 138 + uri: profiles[stream.author.did]?.avatar, 139 + width: 50, 140 + height: 50, 141 + }} 142 + style={[zero.r.full]} 143 + /> 144 + <View 145 + style={[ 146 + zero.layout.flex.row, 147 + zero.gap.all[2], 148 + zero.layout.flex.alignCenter, 149 + { flex: 1, minWidth: 0, width: "100%" }, 150 + ]} 151 + > 152 + <View 153 + style={[ 154 + zero.layout.flex.column, 155 + { flex: 1, minWidth: 0 }, 156 + ]} 157 + > 158 + <Text numberOfLines={1} ellipsizeMode="tail"> 159 + {stream.author?.handle} 160 + </Text> 161 + {stream.record.title ? ( 162 + <Text 163 + color="muted" 164 + ellipsizeMode="tail" 165 + numberOfLines={1} 166 + > 167 + {(stream.record.title as any) || ""} 168 + </Text> 169 + ) : null} 170 + {stream.viewerCount && ( 171 + <Text color="muted"> 172 + {stream.viewerCount.count} viewer 173 + {stream.viewerCount.count !== 1 ? "s" : ""} 174 + </Text> 175 + )} 176 + </View> 177 + </View> 178 + </MenuItem> 179 + </Pressable> 180 + ))} 181 + </MenuGroup> 182 + </ScrollView> 183 + )} 184 + </View> 185 + <DialogFooter> 186 + <Button width="min" variant="secondary" onPress={handleCancel}> 187 + <Text>Cancel</Text> 188 + </Button> 189 + <Button 190 + width="min" 191 + variant="primary" 192 + onPress={handleSubmit} 193 + disabled={!selectedStream} 194 + > 195 + <Text>Teleport</Text> 196 + </Button> 197 + </DialogFooter> 198 + </ResponsiveDialog> 199 + ); 200 + };
+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]",