Skip to content
122 linesCodeBlameRaw
1import * as Notifications from "expo-notifications";
2import { Tabs, router, useGlobalSearchParams } from "expo-router";
3import { Bell, Bot, Code2, House, Menu, MessagesSquare } from "lucide-react-native";
4import type { LucideIcon } from "lucide-react-native";
5import { type ComponentType, useEffect, useRef } from "react";
6import { StyleSheet, View } from "react-native";
7
8import { Banner } from "@/components/chat/banner";
9import { FeedProvider, useFeed } from "@/lib/feed";
10import { routeFor } from "@/lib/links";
11import { type PushData, registerForPush } from "@/lib/push";
12import { useSignedIn } from "@/lib/session";
13import { hasCodeAccess } from "@g1t/contracts";
14import { useColors } from "@/theme";
15
16/**
17 * The signed-in app. Its bar is the site's phone bar
18 * (apps/web/app/components/mobile.tsx): Home, Chat, Notifications, Agents,
19 * Code, and More. Chat and Code have their screens; the others say so and
20 * open the site. Customize and your account are under More.
21 */
22export default function AppLayout() {
23 return (
24 <FeedProvider>
25 <PushSetup />
26 <AppTabs />
27 <Banner />
28 </FeedProvider>
29 );
30}
31
32function AppTabs() {
33 const colors = useColors();
34 const { workspace, selectWorkspace } = useSignedIn();
35 const { unread, mentions } = useFeed();
36 // A link names its workspace (`/chat?workspace=acme`, lib/links.ts): switch to it.
37 const { workspace: linked } = useGlobalSearchParams<{ workspace?: string }>();
38 useEffect(() => {
39 if (linked && linked.toLowerCase() !== workspace?.slug.toLowerCase()) void selectWorkspace(linked);
40 }, [linked, workspace?.slug, selectWorkspace]);
41 const code = !workspace || hasCodeAccess(workspace);
42 const badge = mentions > 0 ? mentions : unread > 0 ? unread : undefined;
43
44 return (
45 <Tabs
46 initialRouteName="chat"
47 screenOptions={{
48 headerShown: false,
49 // The site's phone bar: flat along the bottom on the rail's colour, the
50 // app you are in marked as the rail marks it (apps/web components/mobile.tsx).
51 tabBarActiveTintColor: colors.fg,
52 tabBarInactiveTintColor: colors.faint,
53 tabBarStyle: { backgroundColor: colors.rail, borderTopColor: colors.line, borderTopWidth: StyleSheet.hairlineWidth, elevation: 0 },
54 tabBarLabelStyle: { fontSize: 11, fontWeight: "500" },
55 tabBarIconStyle: { width: 44, height: 28 },
56 }}
57 >
58 <Tabs.Screen name="index" options={{ href: null }} />
59 <Tabs.Screen name="home" options={{ title: "Home", tabBarIcon: (props) => <BarIcon icon={House} {...props} /> }} />
60 <Tabs.Screen
61 name="chat"
62 options={{
63 title: "Chat",
64 tabBarIcon: (props) => <BarIcon icon={MessagesSquare} {...props} />,
65 tabBarBadge: badge,
66 tabBarBadgeStyle: { backgroundColor: mentions > 0 ? colors.accent : colors.lineStrong, color: mentions > 0 ? colors.onAccent : colors.fg, fontSize: 11 },
67 }}
68 />
69 <Tabs.Screen name="notifications" options={{ title: "Notifications", tabBarIcon: (props) => <BarIcon icon={Bell} {...props} /> }} />
70 <Tabs.Screen name="agents" options={{ title: "Agents", tabBarIcon: (props) => <BarIcon icon={Bot} {...props} /> }} />
71 <Tabs.Screen name="code" options={{ title: "Code", href: code ? undefined : null, tabBarIcon: (props) => <BarIcon icon={Code2} {...props} /> }} />
72 <Tabs.Screen name="customize" options={{ href: null }} />
73 <Tabs.Screen name="more" options={{ title: "More", tabBarIcon: (props) => <BarIcon icon={Menu} {...props} /> }} />
74 </Tabs>
75 );
76}
77
78/** A tab's icon: on the rail's accent in a 10px-rounded square when it is the tab you are in. */
79function BarIcon({ icon: Icon, focused }: { icon: LucideIcon | ComponentType<{ color: string; size: number }>; focused: boolean }) {
80 const colors = useColors();
81 return (
82 <View style={{ width: 44, height: 28, borderRadius: 10, alignItems: "center", justifyContent: "center", backgroundColor: focused ? colors.railAccent : "transparent" }}>
83 <Icon color={focused ? colors.fg : colors.muted} size={20} />
84 </View>
85 );
86}
87
88/**
89 * Registers the phone for notifications once signed in (asking the first
90 * time), and opens what a tapped notification is about: the thread or
91 * conversation, in its workspace.
92 */
93function PushSetup() {
94 const { client, selectWorkspace } = useSignedIn();
95 const handled = useRef<string | null>(null);
96 const response = Notifications.useLastNotificationResponse();
97
98 useEffect(() => {
99 void registerForPush(client, true).catch((error: unknown) => console.warn("g1t: registering for notifications failed", error));
100 }, [client]);
101
102 useEffect(() => {
103 if (!response || response.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) return;
104 const id = response.notification.request.identifier;
105 if (handled.current === id) return;
106 handled.current = id;
107 const data = (response.notification.request.content.data ?? {}) as PushData;
108 void (async () => {
109 if (data.workspace) await selectWorkspace(data.workspace);
110 if (data.channel_id && data.thread_root) {
111 router.push({ pathname: "/chat/thread", params: { channel: data.channel_id, root: data.thread_root } });
112 } else if (data.channel_id) {
113 router.push({ pathname: "/chat/[channel]", params: { channel: data.channel_id } });
114 } else if (data.href) {
115 const route = routeFor(data.href);
116 router.push((route ?? `/open?path=${encodeURIComponent(data.href)}`) as never);
117 }
118 })();
119 }, [response, selectWorkspace]);
120
121 return null;
122}