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