Live video on the AT Protocol

add chat popout & color customizer

See merge request streamplace/streamplace!129

Changelog: feature

Eli Streams ba367857 66ac87d4

+201 -99
+94 -74
js/app/components/chat/chat-box.tsx
··· 1 1 import { useNavigation } from "@react-navigation/native"; 2 - import { Send } from "@tamagui/lucide-icons"; 3 2 import { useToastController } from "@tamagui/toast"; 4 3 import { 5 4 chatMessage, ··· 15 14 import { Keyboard } from "react-native"; 16 15 import { useAppDispatch, useAppSelector } from "store/hooks"; 17 16 import { Button, Form, Input, isWeb, TextArea, View } from "tamagui"; 17 + import { Palette, SquareArrowOutUpRight } from "@tamagui/lucide-icons"; 18 + import NameColorPicker from "components/name-color-picker/name-color-picker"; 18 19 19 - export default function ChatBox() { 20 + export default function ChatBox({ isPopout }: { isPopout?: boolean }) { 20 21 const [message, setMessage] = useState(""); 21 22 const isReady = useAppSelector(selectIsReady); 22 23 const userProfile = useAppSelector(selectUserProfile); ··· 42 43 const textarea = textAreaRef.current as unknown as HTMLTextAreaElement; 43 44 textarea.style.height = ""; 44 45 } 45 - if (!isWeb) { 46 - console.log(textAreaRef.current); 47 - } 48 46 }; 49 47 50 48 const toast = useToastController(); ··· 52 50 return ( 53 51 <View position="relative"> 54 52 {loggedOut && <Login />} 55 - <Form 56 - zIndex={1} 57 - flexDirection="row" 58 - padding={2} 59 - alignItems="center" 60 - opacity={loggedOut ? 0 : 1} 61 - > 62 - <View flexGrow={1} flexShrink={1}> 63 - <TextArea 64 - borderRadius={0} 65 - overflow="hidden" 66 - returnKeyType="done" 67 - submitBehavior="blurAndSubmit" 68 - value={message} 69 - ref={textAreaRef} 70 - multiline={true} 71 - keyboardType="default" 72 - disabled={loggedOut} 73 - rows={1} 74 - onPress={() => { 75 - if (!chatWarned) { 76 - dispatch(chatWarn(true)); 77 - toast.show("Just so you know!", { 78 - message: `Streamplace chat messages are public in the same way that Bluesky posts are public - they create records on your PDS.`, 79 - }); 80 - } 81 - }} 82 - onChangeText={(text) => { 83 - const newMessage = text.replaceAll("\n", ""); 84 - if (newMessage.length > 300) { 85 - return; 86 - } 87 - setMessage(text.replaceAll("\n", "")); 88 - if (isWeb && textAreaRef.current) { 89 - const textarea = 90 - textAreaRef.current as unknown as HTMLTextAreaElement; 91 - textarea.style.height = ""; 92 - textarea.style.height = textarea.scrollHeight + "px"; 93 - } 94 - }} 95 - onKeyPress={(e) => { 96 - if (e.nativeEvent.key === "Enter") { 97 - e.preventDefault(); 98 - submit(); 99 - } 100 - }} 101 - onSubmitEditing={submit} 102 - /> 103 - </View> 104 - <Button 105 - flexShrink={0} 106 - backgroundColor="transparent" 107 - disabled={loggedOut} 108 - onPress={() => { 109 - submit(); 110 - }} 53 + {!loggedOut && ( 54 + <Form 55 + zIndex={1} 56 + flexDirection="column" 57 + padding={2} 58 + alignItems="stretch" 59 + opacity={loggedOut ? 0 : 1} 111 60 > 112 - <Send /> 113 - </Button> 114 - </Form> 61 + <View flexGrow={1} flexShrink={0}> 62 + <TextArea 63 + borderRadius={0} 64 + overflow="hidden" 65 + returnKeyType="done" 66 + submitBehavior="blurAndSubmit" 67 + value={message} 68 + ref={textAreaRef} 69 + multiline={true} 70 + keyboardType="default" 71 + disabled={loggedOut} 72 + rows={1} 73 + onPress={() => { 74 + if (!chatWarned) { 75 + dispatch(chatWarn(true)); 76 + toast.show("Just so you know!", { 77 + message: `Streamplace chat messages are public in the same way that Bluesky posts are public - they create records on your PDS.`, 78 + }); 79 + } 80 + }} 81 + onChangeText={(text) => { 82 + const newMessage = text.replaceAll("\n", ""); 83 + // const rt = new RichText({ text: newMessage }); 84 + // rt.detectFacetsWithoutResolution(); 85 + if (newMessage.length > 300) { 86 + return; 87 + } 88 + setMessage(text.replaceAll("\n", "")); 89 + if (isWeb && textAreaRef.current) { 90 + const textarea = 91 + textAreaRef.current as unknown as HTMLTextAreaElement; 92 + textarea.style.height = ""; 93 + textarea.style.height = textarea.scrollHeight + "px"; 94 + } 95 + }} 96 + onKeyPress={(e) => { 97 + if (e.nativeEvent.key === "Enter") { 98 + e.preventDefault(); 99 + submit(); 100 + } 101 + }} 102 + onSubmitEditing={submit} 103 + /> 104 + </View> 105 + <View 106 + flexDirection="row" 107 + justifyContent="flex-end" 108 + flexGrow={1} 109 + flexShrink={0} 110 + > 111 + <NameColorPicker 112 + buttonProps={{ backgroundColor: "transparent" }} 113 + text={(color) => <Palette size={16} color={color} />} 114 + /> 115 + {isWeb && !isPopout && ( 116 + <Button 117 + flexShrink={0} 118 + backgroundColor="transparent" 119 + disabled={loggedOut} 120 + onPress={() => { 121 + window.open( 122 + "http://127.0.0.1:38080/chat-popout/iame.li", 123 + "_blank", 124 + "popup=true", 125 + ); 126 + }} 127 + > 128 + <SquareArrowOutUpRight size={16} /> 129 + </Button> 130 + )} 131 + <Button 132 + flexShrink={0} 133 + backgroundColor="transparent" 134 + disabled={loggedOut} 135 + onPress={() => { 136 + submit(); 137 + }} 138 + > 139 + Send 140 + </Button> 141 + </View> 142 + </Form> 143 + )} 115 144 </View> 116 145 ); 117 146 } ··· 119 148 const Login = () => { 120 149 const navigate = useNavigation(); 121 150 return ( 122 - <View 123 - position="absolute" 124 - top={0} 125 - left={0} 126 - right={0} 127 - bottom={0} 128 - justifyContent="center" 129 - alignItems="center" 130 - zIndex={2} 131 - > 151 + <View alignItems="center"> 132 152 <Button 133 153 backgroundColor="$accentColor" 134 154 onPress={() => {
-1
js/app/components/chat/chat.tsx
··· 204 204 205 205 const ChatMessageText = ({ message }: { message: MessageViewHydrated }) => { 206 206 let color = "$accentColor"; 207 - console.log(message.chatProfile); 208 207 if (message.chatProfile?.color) { 209 208 const { red, green, blue } = message.chatProfile.color; 210 209 color = `rgb(${red}, ${green}, ${blue})`;
+8 -1
js/app/components/login/login.tsx
··· 38 38 Log out 39 39 </Button> 40 40 </View> 41 - <NameColorPicker /> 41 + <NameColorPicker 42 + buttonProps={{ 43 + textAlign: "center", 44 + flexBasis: 250, 45 + maxWidth: 300, 46 + marginHorizontal: "auto", 47 + }} 48 + /> 42 49 </View> 43 50 ); 44 51 }
+48 -19
js/app/components/name-color-picker/name-color-picker.tsx
··· 1 + import { X } from "@tamagui/lucide-icons"; 1 2 import { 2 3 createChatProfileRecord, 3 4 getChatProfileRecordFromPDS, ··· 6 7 } from "features/bluesky/blueskySlice"; 7 8 import { PlaceStreamChatProfile } from "lexicons"; 8 9 import { useEffect, useState } from "react"; 10 + import { Keyboard } from "react-native"; 9 11 import ColorPicker, { 10 12 HueSlider, 11 13 Panel1, ··· 13 15 Swatches, 14 16 } from "reanimated-color-picker"; 15 17 import { useAppDispatch, useAppSelector } from "store/hooks"; 16 - import { Button, H3, Sheet, useTheme, View } from "tamagui"; 18 + import { Button, H3, isWeb, Sheet, useTheme, View } from "tamagui"; 17 19 18 20 /** 19 21 * Parses an RGB color string and returns an object with red, green, and blue values ··· 37 39 throw new Error("Invalid color string (not enough parts)"); 38 40 } 39 41 40 - console.log(parts); 41 42 return { 42 43 red: parseInt(parts[0].trim(), 10), 43 44 green: parseInt(parts[1].trim(), 10), ··· 45 46 }; 46 47 } 47 48 48 - export default function NameColorPicker() { 49 + export default function NameColorPicker({ 50 + children, 51 + text, 52 + buttonProps, 53 + }: { 54 + children?: React.ReactNode; 55 + text?: (color: string) => React.ReactNode; 56 + buttonProps?: React.ComponentProps<typeof Button>; 57 + }) { 49 58 const theme = useTheme(); 50 59 const [open, setOpen] = useState(false); 51 60 const dispatch = useAppDispatch(); ··· 53 62 const [color, setColor] = useState(theme.accentColor?.val ?? "#bd6e86"); 54 63 const profile = useAppSelector(selectUserProfile); 55 64 65 + let startColor = ""; 66 + if (chatProfile?.profile?.color) { 67 + startColor = `rgb(${chatProfile.profile.color.red}, ${chatProfile.profile.color.green}, ${chatProfile.profile.color.blue})`; 68 + } 69 + 56 70 useEffect(() => { 57 71 if (!chatProfile?.profile) { 58 72 dispatch(getChatProfileRecordFromPDS()); ··· 61 75 const { red, green, blue } = chatProfile.profile.color; 62 76 setColor(`rgb(${red}, ${green}, ${blue})`); 63 77 } 64 - }, [!chatProfile?.profile]); 65 - // onCompleteJS={(x) => setColor(x.rgb)} 78 + }, [startColor]); 79 + 66 80 return ( 67 81 <View alignItems="center" flexDirection="row"> 68 82 <Button 69 - maxWidth={300} 70 - textAlign="center" 83 + {...buttonProps} 71 84 color={color} 72 - marginHorizontal="auto" 73 - onPress={() => setOpen(true)} 74 - flexBasis={250} 75 - // textShadowColor="white" 76 - // textShadowOffset={{ width: 0, height: 0 }} 77 - // textShadowRadius={3} 85 + onPress={() => { 86 + if (!isWeb) { 87 + Keyboard.dismiss(); 88 + } 89 + setOpen(true); 90 + }} 78 91 > 79 - Change Name Color 92 + {text ? text(color) : "Change Name Color"} 80 93 </Button> 81 94 <Sheet 82 95 // forceRemoveScrollEnabled={open} 83 96 open={open} 84 97 modal={true} 85 - onOpenChange={setOpen} 86 - // snapPoints={snapPoints} 87 - // snapPointsMode={snapPointsMode} 98 + onOpenChange={(open) => { 99 + setOpen(open); 100 + if (!open) { 101 + dispatch(getChatProfileRecordFromPDS()); 102 + } 103 + }} 88 104 dismissOnSnapToBottom 89 - // position={position} 90 - // onPositionChange={setPosition} 105 + disableDrag={true} 91 106 zIndex={100_000} 92 107 animation="medium" 93 108 > ··· 108 123 maxWidth={600} 109 124 marginHorizontal="auto" 110 125 > 126 + <Button 127 + position="absolute" 128 + top="$0" 129 + right="$0" 130 + onPress={(e) => { 131 + e.stopPropagation(); 132 + setOpen(false); 133 + }} 134 + marginRight={-15} 135 + marginTop={-5} 136 + backgroundColor="transparent" 137 + > 138 + <X /> 139 + </Button> 111 140 <H3 textAlign="center" color={color}> 112 141 @{profile?.handle} 113 142 </H3>
+12
js/app/src/router.tsx
··· 55 55 import { useToastController } from "@tamagui/toast"; 56 56 import LiveDashboard from "./screens/live-dashboard"; 57 57 import Popup from "components/popup"; 58 + import PopoutChat from "./screens/chat-popout"; 58 59 function HomeScreen() { 59 60 return ( 60 61 <View f={1}> ··· 81 82 AppReturn: { scheme: string }; 82 83 About: undefined; 83 84 Download: undefined; 85 + PopoutChat: { user: string }; 84 86 }; 85 87 86 88 declare global { ··· 111 113 AppReturn: "app-return/:scheme", 112 114 About: "about", 113 115 Download: "download", 116 + PopoutChat: "chat-popout/:user", 114 117 }, 115 118 }, 116 119 }; ··· 351 354 options={{ 352 355 drawerIcon: () => <LogIn />, 353 356 drawerLabel: () => <Text>Login</Text>, 357 + }} 358 + /> 359 + <Drawer.Screen 360 + name="PopoutChat" 361 + component={PopoutChat} 362 + options={{ 363 + drawerLabel: () => null, 364 + drawerItemStyle: { display: "none" }, 365 + headerShown: false, 354 366 }} 355 367 /> 356 368 </Drawer.Navigator>
+5
js/app/src/screens/chat-popout.native.tsx
··· 1 + import { View } from "react-native"; 2 + 3 + export default function PopoutChat({ route }) { 4 + return <View />; 5 + }
+30
js/app/src/screens/chat-popout.tsx
··· 1 + import Chat from "components/chat/chat"; 2 + import ChatBox from "components/chat/chat-box"; 3 + import PlayerProvider from "components/player/provider"; 4 + import { selectUserProfile } from "features/bluesky/blueskySlice"; 5 + import { useAppSelector } from "store/hooks"; 6 + import { View } from "tamagui"; 7 + 8 + export default function PopoutChat({ route }) { 9 + const user = route.params?.user; 10 + if (typeof user !== "string") { 11 + return <View />; 12 + } 13 + const profile = useAppSelector(selectUserProfile); 14 + return ( 15 + <PlayerProvider src={user}> 16 + <View position="relative" f={1}> 17 + <View 18 + f={1} 19 + position="absolute" 20 + width="100%" 21 + minHeight="100%" 22 + bottom={0} 23 + > 24 + <Chat /> 25 + {profile && <ChatBox isPopout={true} />} 26 + </View> 27 + </View> 28 + </PlayerProvider> 29 + ); 30 + }
+4 -4
js/desktop/src/tests/playback-test.ts
··· 20 20 21 21 const testId = uuidv7(); 22 22 const definitions = [ 23 - { 24 - name: "hls", 25 - forceProtocol: "hls", 26 - }, 23 + // { 24 + // name: "hls", 25 + // forceProtocol: "hls", 26 + // }, 27 27 { 28 28 name: "progressive-mp4", 29 29 forceProtocol: "progressive-mp4",