Live video on the AT Protocol
at eli/multitesting 652 lines 18 kB view raw
1import "@expo/metro-runtime"; 2import { 3 createDrawerNavigator, 4 DrawerContentScrollView, 5 DrawerItem, 6 DrawerItemList, 7} from "@react-navigation/drawer"; 8import { 9 CommonActions, 10 DrawerActions, 11 LinkingOptions, 12 NavigatorScreenParams, 13 useLinkTo, 14 useNavigation, 15 useRoute, 16} from "@react-navigation/native"; 17import { createNativeStackNavigator } from "@react-navigation/native-stack"; 18import { Text, useTheme, useToast } from "@streamplace/components"; 19import { Provider, Settings } from "components"; 20import AQLink from "components/aqlink"; 21import Login from "components/login/login"; 22import Sidebar, { ExternalDrawerItem } from "components/sidebar/sidebar"; 23import * as ExpoLinking from "expo-linking"; 24import { hydrate, selectHydrated } from "features/base/baseSlice"; 25import { selectUserProfile } from "features/bluesky/blueskySlice"; 26import { 27 clearNotification, 28 initPushNotifications, 29 registerNotificationToken, 30 selectNotificationDestination, 31 selectNotificationToken, 32} from "features/platform/platformSlice.native"; 33import { pollMySegments } from "features/streamplace/streamplaceSlice"; 34import { useLiveUser } from "hooks/useLiveUser"; 35import usePlatform from "hooks/usePlatform"; 36import { useSidebarControl } from "hooks/useSidebarControl"; 37import { 38 ArrowLeft, 39 Book, 40 Download, 41 ExternalLink, 42 Home, 43 LogIn, 44 Menu, 45 PanelLeftClose, 46 PanelLeftOpen, 47 Settings as SettingsIcon, 48 ShieldQuestion, 49 User, 50 Video, 51} from "lucide-react-native"; 52import React, { Fragment, useEffect, useState } from "react"; 53import { 54 ImageBackground, 55 ImageSourcePropType, 56 Linking, 57 Platform, 58 Pressable, 59 StatusBar, 60 View, 61} from "react-native"; 62import { useAppDispatch, useAppSelector } from "store/hooks"; 63import AboutScreen from "./screens/about"; 64import AppReturnScreen from "./screens/app-return"; 65import PopoutChat from "./screens/chat-popout"; 66import DownloadScreen from "./screens/download"; 67import EmbedScreen from "./screens/embed"; 68import InfoWidgetEmbed from "./screens/info-widget-embed"; 69import LiveDashboard from "./screens/live-dashboard"; 70import MultiScreen from "./screens/multi"; 71import SupportScreen from "./screens/support"; 72 73import KeyManager from "components/settings/key-manager"; 74import { loadStateFromStorage } from "features/base/sidebarSlice"; 75import { store } from "store/store"; 76import HomeScreen from "./screens/home"; 77 78import { useUrl } from "@streamplace/components"; 79import Constants from "expo-constants"; 80import { SystemBars } from "react-native-edge-to-edge"; 81import { 82 configureReanimatedLogger, 83 ReanimatedLogLevel, 84 useAnimatedStyle, 85} from "react-native-reanimated"; 86import MobileGoLive from "./screens/mobile-go-live"; 87import MobileStream from "./screens/mobile-stream"; 88store.dispatch(loadStateFromStorage()); 89 90const Stack = createNativeStackNavigator(); 91 92// disabled strict b/c chat swipeable triggers it a LOT and the resulting logging 93// slows down the whole app 94configureReanimatedLogger({ 95 level: ReanimatedLogLevel.warn, 96 strict: false, 97}); 98 99type HomeStackParamList = { 100 StreamList: undefined; 101 Stream: { user: string }; 102}; 103 104type RootStackParamList = { 105 Home: NavigatorScreenParams<HomeStackParamList>; 106 Multi: { config: string }; 107 Support: undefined; 108 Settings: undefined; 109 KeyManagement: undefined; 110 GoLive: undefined; 111 LiveDashboard: undefined; 112 Login: undefined; 113 AVSync: undefined; 114 AppReturn: { scheme: string }; 115 About: undefined; 116 Download: undefined; 117 PopoutChat: { user: string }; 118 Embed: { user: string }; 119 InfoWidgetEmbed: undefined; 120 LegacyStream: { user: string }; 121 MobileGoLive: undefined; 122}; 123 124declare global { 125 namespace ReactNavigation { 126 interface RootParamList extends RootStackParamList {} 127 } 128} 129 130const linking: LinkingOptions<ReactNavigation.RootParamList> = { 131 prefixes: [ExpoLinking.createURL("")], 132 config: { 133 screens: { 134 Home: { 135 screens: { 136 StreamList: "", 137 Stream: { 138 path: ":user", 139 }, 140 }, 141 }, 142 Multi: "multi/:config", 143 Support: "support", 144 Settings: "settings", 145 KeyManagement: "key-management", 146 GoLive: "golive", 147 LiveDashboard: "live", 148 Login: "login", 149 AVSync: "sync-test", 150 AppReturn: "app-return/:scheme", 151 About: "about", 152 Download: "download", 153 PopoutChat: "chat-popout/:user", 154 Embed: "embed/:user", 155 InfoWidgetEmbed: "info-widget", 156 LegacyStream: "legacy/:user", 157 MobileGoLive: "mobile-golive", 158 }, 159 }, 160}; 161 162const associatedDomain = Constants.expoConfig?.ios?.associatedDomains?.[0]; 163if (associatedDomain && associatedDomain.startsWith("applinks:")) { 164 const domain = associatedDomain.slice("applinks:".length); 165 linking.prefixes.push(`https://${domain}`); 166} 167 168// https://github.com/streamplace/streamplace/issues/377 169const hasDevDomain = linking.prefixes.some((prefix) => 170 prefix.includes("tv.aquareum.dev"), 171); 172if (hasDevDomain) { 173 linking.prefixes.push("tv.aquareum://"); 174 linking.prefixes.push("https://stream.place"); 175} 176 177console.log("Linking prefixes", linking.prefixes); 178 179const Drawer = createDrawerNavigator(); 180 181const NavigationButton = ({ canGoBack }: { canGoBack?: boolean }) => { 182 const sidebar = useSidebarControl(); 183 const navigation = useNavigation(); 184 const { theme } = useTheme(); 185 186 const handlePress = () => { 187 if (sidebar?.isActive) { 188 sidebar.toggle(); 189 } 190 }; 191 192 const handleGoBackPress = () => { 193 if (canGoBack) { 194 navigation.goBack(); 195 } else { 196 navigation.dispatch(DrawerActions.toggleDrawer()); 197 } 198 }; 199 200 return ( 201 <View 202 style={[ 203 { flexDirection: "row" }, 204 { 205 marginLeft: Platform.OS === "android" ? 0 : 12, 206 marginRight: Platform.OS === "android" ? 12 : 0, 207 }, 208 ]} 209 > 210 {sidebar?.isActive ? ( 211 <Pressable style={{ padding: 5 }} onPress={handlePress}> 212 {sidebar.isCollapsed ? ( 213 <PanelLeftOpen size={24} color={theme.colors.accentForeground} /> 214 ) : ( 215 <PanelLeftClose size={24} color={theme.colors.accentForeground} /> 216 )} 217 </Pressable> 218 ) : ( 219 <Pressable style={{ padding: 5 }} onPress={handleGoBackPress}> 220 {canGoBack ? ( 221 <ArrowLeft size={24} color={theme.colors.accentForeground} /> 222 ) : ( 223 <Menu size={24} color={theme.colors.accentForeground} /> 224 )} 225 </Pressable> 226 )} 227 </View> 228 ); 229}; 230 231const AvatarButton = () => { 232 const userProfile = useAppSelector(selectUserProfile); 233 let source: ImageSourcePropType | undefined = undefined; 234 let opacity = 1; 235 if (userProfile) { 236 source = { uri: userProfile.avatar }; 237 opacity = 0; 238 } 239 return ( 240 <AQLink to={{ screen: "Login", params: {} }}> 241 <ImageBackground 242 // defeat cursed-ass caching on ios; image sticks around when source is undefined 243 key={source?.uri ?? "default"} 244 source={source} 245 style={{ 246 width: 40, 247 height: 40, 248 borderRadius: 24, 249 overflow: "hidden", 250 marginRight: 10, 251 backgroundColor: "black", 252 justifyContent: "center", 253 alignItems: "center", 254 }} 255 > 256 <User size={24} color="white" style={{ zIndex: -2 }} /> 257 </ImageBackground> 258 </AQLink> 259 ); 260}; 261 262const useExternalItems = (): ExternalDrawerItem[] => { 263 const streamplaceUrl = useUrl(); 264 const { theme } = useTheme(); 265 return [ 266 { 267 item: React.memo(() => <Book size={24} color={theme.colors.text} />), 268 label: ( 269 <Text variant="h5" style={{ alignSelf: "flex-start" }}> 270 Documentation{" "} 271 <ExternalLink 272 size={16} 273 color={theme.colors.mutedForeground} 274 style={{ 275 position: "relative", 276 top: 2, 277 }} 278 /> 279 </Text> 280 ) as any, 281 onPress: () => { 282 const u = new URL(streamplaceUrl); 283 u.pathname = "/docs"; 284 Linking.openURL(u.toString()); 285 }, 286 }, 287 ]; 288}; 289 290// TODO: merge in ^ 291function CustomDrawerContent(props) { 292 let { theme } = useTheme(); 293 return ( 294 <DrawerContentScrollView {...props}> 295 <DrawerItemList {...props} /> 296 <DrawerItem 297 icon={() => <Book size={24} color={theme.colors.text} />} 298 label={() => ( 299 <Text style={{ alignSelf: "flex-start" }}> 300 Documentation{" "} 301 <ExternalLink 302 size={16} 303 color="#666" 304 style={{ 305 position: "relative", 306 top: 2, 307 }} 308 /> 309 </Text> 310 )} 311 onPress={() => { 312 const u = new URL(window.location.href); 313 u.pathname = "/docs"; 314 Linking.openURL(u.toString()); 315 }} 316 /> 317 </DrawerContentScrollView> 318 ); 319} 320 321export default function Router() { 322 return ( 323 <Provider linking={linking}> 324 <StreamplaceDrawer /> 325 </Provider> 326 ); 327} 328 329export function StreamplaceDrawer() { 330 const theme = useTheme(); 331 const { isWeb, isElectron, isNative, isBrowser } = usePlatform(); 332 const navigation = useNavigation(); 333 const dispatch = useAppDispatch(); 334 const [livePopup, setLivePopup] = useState(false); 335 336 const sidebar = useSidebarControl(); 337 338 const toast = useToast(); 339 340 SystemBars.setStyle("dark"); 341 342 // Top-level stuff to handle push notification registration 343 useEffect(() => { 344 dispatch(hydrate()); 345 dispatch(initPushNotifications()); 346 }, []); 347 const notificationToken = useAppSelector(selectNotificationToken); 348 const userProfile = useAppSelector(selectUserProfile); 349 const hydrated = useAppSelector(selectHydrated); 350 useEffect(() => { 351 if (notificationToken) { 352 dispatch(registerNotificationToken()); 353 } 354 }, [notificationToken, userProfile]); 355 356 // Stuff to handle incoming push notification routing 357 const notificationDestination = useAppSelector(selectNotificationDestination); 358 const linkTo = useLinkTo(); 359 360 const animatedDrawerStyle = useAnimatedStyle(() => { 361 return { 362 width: sidebar.isActive ? sidebar.animatedWidth.value : undefined, 363 }; 364 }); 365 366 useEffect(() => { 367 if (notificationDestination) { 368 linkTo(notificationDestination); 369 dispatch(clearNotification()); 370 } 371 }, [notificationDestination]); 372 373 // Top-level stuff to handle polling for live streamers 374 useEffect(() => { 375 let handle: NodeJS.Timeout; 376 handle = setInterval(() => { 377 dispatch(pollMySegments()); 378 }, 2500); 379 dispatch(pollMySegments()); 380 return () => clearInterval(handle); 381 }, []); 382 383 const userIsLive = useLiveUser(); 384 // Note: Toast functionality removed, would need simple alert replacement 385 386 let foregroundColor = theme.theme.colors.text || "#fff"; 387 388 // are we in the live dashboard? 389 const [isLiveDashboard, setIsLiveDashboard] = useState(false); 390 useEffect(() => { 391 if (!isLiveDashboard && userIsLive) { 392 toast.show("You are live!", "Do you want to go to your Live Dashboard?", { 393 actionLabel: "Go", 394 onAction: () => { 395 navigation.navigate("LiveDashboard"); 396 setLivePopup(false); 397 }, 398 onClose: () => setLivePopup(false), 399 variant: "error", 400 duration: 8, 401 }); 402 } 403 }, [userIsLive]); 404 const externalItems = useExternalItems(); 405 406 if (!hydrated) { 407 return <View />; 408 } 409 410 return ( 411 <> 412 <StatusBar barStyle="light-content" /> 413 <Drawer.Navigator 414 initialRouteName="Home" 415 screenOptions={{ 416 // for the custom sidebar 417 drawerType: sidebar.isActive ? "permanent" : "front", 418 swipeEnabled: !sidebar.isActive, 419 drawerStyle: [ 420 { 421 zIndex: 128000, 422 }, 423 sidebar.isActive ? animatedDrawerStyle : [], 424 ], 425 // rest 426 headerLeft: () => ( 427 <> 428 {/* this is a hack to give the popup the navigator context */} 429 <PopupChecker setIsLiveDashboard={setIsLiveDashboard} /> 430 <NavigationButton /> 431 </> 432 ), 433 headerRight: () => <AvatarButton />, 434 drawerActiveTintColor: "#a0287c33", 435 unmountOnBlur: true, 436 }} 437 drawerContent={ 438 sidebar.isActive 439 ? (props) => ( 440 <Sidebar 441 {...props} 442 collapsed={sidebar.isCollapsed} 443 hidden={sidebar.isHidden} 444 widthAnim={sidebar.animatedWidth} 445 externalItems={externalItems} 446 /> 447 ) 448 : CustomDrawerContent 449 } 450 > 451 <Drawer.Screen 452 name="Home" 453 component={MainTab} 454 options={{ 455 drawerIcon: () => <Home color={foregroundColor} size={24} />, 456 drawerLabel: () => <Text variant="h5">Home</Text>, 457 headerTitle: "Streamplace", 458 headerShown: isWeb, 459 title: "Streamplace", 460 }} 461 listeners={{ 462 drawerItemPress: (e) => { 463 e.preventDefault(); 464 navigation.dispatch( 465 CommonActions.reset({ 466 index: 0, 467 routes: [ 468 { 469 name: "Home", 470 state: { 471 routes: [{ name: "StreamList" }], 472 }, 473 }, 474 ], 475 }), 476 ); 477 }, 478 }} 479 /> 480 <Drawer.Screen 481 name="About" 482 component={AboutScreen} 483 options={{ 484 drawerLabel: () => <Text variant="h5">What's Streamplace?</Text>, 485 drawerIcon: () => ( 486 <ShieldQuestion color={foregroundColor} size={24} /> 487 ), 488 drawerItemStyle: isNative ? { display: "none" } : undefined, 489 }} 490 /> 491 <Drawer.Screen 492 name="Download" 493 component={DownloadScreen} 494 options={{ 495 drawerLabel: () => <Text variant="h5">Download</Text>, 496 drawerIcon: () => <Download color={foregroundColor} size={24} />, 497 drawerItemStyle: isBrowser ? undefined : { display: "none" }, 498 }} 499 /> 500 <Drawer.Screen 501 name="Settings" 502 component={Settings} 503 options={{ 504 drawerIcon: () => ( 505 <SettingsIcon color={foregroundColor} size={24} /> 506 ), 507 drawerLabel: () => <Text variant="h5">Settings</Text>, 508 }} 509 /> 510 511 <Drawer.Screen 512 name="KeyManagement" 513 component={KeyManager} 514 options={{ 515 drawerLabel: () => <Text variant="h5">Key Manager</Text>, 516 drawerItemStyle: { display: "none" }, 517 }} 518 /> 519 <Drawer.Screen 520 name="Support" 521 component={SupportScreen} 522 options={{ 523 drawerLabel: () => <Text variant="h5">Support</Text>, 524 drawerItemStyle: { display: "none" }, 525 }} 526 /> 527 <Drawer.Screen 528 name="LiveDashboard" 529 component={LiveDashboard} 530 options={{ 531 drawerLabel: () => <Text variant="h5">Live Dashboard</Text>, 532 drawerIcon: () => <Video color={foregroundColor} size={24} />, 533 drawerItemStyle: isNative ? { display: "none" } : undefined, 534 }} 535 /> 536 <Drawer.Screen 537 name="AppReturn" 538 component={AppReturnScreen} 539 options={{ 540 drawerLabel: () => null, 541 drawerItemStyle: { display: "none" }, 542 headerShown: false, 543 }} 544 /> 545 <Drawer.Screen 546 name="Multi" 547 component={MultiScreen} 548 options={{ 549 drawerLabel: () => null, 550 drawerItemStyle: { display: "none" }, 551 }} 552 /> 553 <Drawer.Screen 554 name="Login" 555 component={Login} 556 options={{ 557 drawerIcon: () => <LogIn color={foregroundColor} size={24} />, 558 drawerLabel: () => <Text variant="h5">Login</Text>, 559 }} 560 /> 561 <Drawer.Screen 562 name="PopoutChat" 563 component={PopoutChat} 564 options={{ 565 drawerLabel: () => null, 566 drawerItemStyle: { display: "none" }, 567 headerShown: false, 568 drawerStyle: { display: "none" }, 569 }} 570 /> 571 <Drawer.Screen 572 name="Embed" 573 component={EmbedScreen} 574 options={{ 575 drawerLabel: () => null, 576 drawerItemStyle: { display: "none" }, 577 headerShown: false, 578 }} 579 /> 580 <Drawer.Screen 581 name="InfoWidgetEmbed" 582 component={InfoWidgetEmbed} 583 options={{ 584 drawerLabel: () => null, 585 drawerItemStyle: { display: "none" }, 586 headerShown: false, 587 }} 588 /> 589 <Drawer.Screen 590 name="MobileGoLive" 591 component={MobileGoLive} 592 options={{ 593 headerTitle: "Go Live", 594 drawerItemStyle: isNative ? undefined : { display: "none" }, 595 drawerLabel: () => <Text variant="h5">Go Live</Text>, 596 title: "Go live", 597 drawerIcon: () => <Video color={foregroundColor} size={24} />, 598 headerShown: false, 599 }} 600 /> 601 </Drawer.Navigator> 602 </> 603 ); 604} 605 606export const PopupChecker = ({ 607 setIsLiveDashboard, 608}: { 609 setIsLiveDashboard: (isLiveDashboard: boolean) => void; 610}) => { 611 const route = useRoute(); 612 useEffect(() => { 613 if (route.name === "LiveDashboard") { 614 setIsLiveDashboard(true); 615 } else { 616 setIsLiveDashboard(false); 617 } 618 }, [route.name]); 619 return <Fragment />; 620}; 621 622const MainTab = () => { 623 const theme = useTheme(); 624 const { isWeb } = usePlatform(); 625 return ( 626 <Stack.Navigator 627 initialRouteName="StreamList" 628 screenOptions={{ 629 headerLeft: ({ canGoBack }) => ( 630 <NavigationButton canGoBack={canGoBack} /> 631 ), 632 headerRight: () => <AvatarButton />, 633 headerShown: !isWeb, 634 }} 635 > 636 <Stack.Screen 637 name="StreamList" 638 component={HomeScreen} 639 options={{ headerTitle: "Streamplace", title: "Streamplace" }} 640 /> 641 <Stack.Screen 642 name="Stream" 643 component={MobileStream} 644 options={{ 645 headerTitle: "Stream", 646 title: "Streamplace Stream", 647 headerShown: false, 648 }} 649 /> 650 </Stack.Navigator> 651 ); 652};