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