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 { 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};