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