Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

146 linesCodeBlameRaw
1import * as Notifications from "expo-notifications";
2import { Tabs, router, useGlobalSearchParams, useSegments } from "expo-router";
3import { Bell, House, ListTodo, 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 { useColors } from "@/theme";
14
15/**
16 * The signed-in app. Its bar is the site's phone bar
17 * (apps/web/app/components/mobile.tsx): Home, Work, Chat, Notifications
18 * and More. Work and Chat have their screens; Home and Notifications say
19 * so and open the site. Sessions are Work's (Items, Board, Sessions at
20 * its top, as on the site). More starts with Explore, which opens on the
21 * site, then Code, for those who use it, and your account is under it.
22 */
23export default function AppLayout() {
24 return (
25 <FeedProvider>
26 <PushSetup />
27 <AppTabs />
28 <Banner />
29 </FeedProvider>
30 );
31}
32
33function AppTabs() {
34 const colors = useColors();
35 const { workspace, selectWorkspace } = useSignedIn();
36 const { unread, mentions } = useFeed();
37 // A link names its workspace (`/chat?workspace=acme`, lib/links.ts): switch to it.
38 const { workspace: linked } = useGlobalSearchParams<{ workspace?: string }>();
39 useEffect(() => {
40 if (linked && linked.toLowerCase() !== workspace?.slug.toLowerCase()) void selectWorkspace(linked);
41 }, [linked, workspace?.slug, selectWorkspace]);
42 // A conversation or a thread takes the whole screen, as on the site: the bar steps aside.
43 const segments = useSegments() as string[];
44 const inConversation = segments[1] === "chat" && (segments[2] === "[channel]" || segments[2] === "thread");
45 const badge = mentions > 0 ? mentions : unread > 0 ? unread : undefined;
46
47 return (
48 <Tabs
49 initialRouteName="chat"
50 screenOptions={{
51 headerShown: false,
52 // The site's phone bar: flat along the bottom on the rail's colour, the
53 // app you are in marked as the rail marks it (apps/web components/mobile.tsx).
54 tabBarActiveTintColor: colors.fg,
55 tabBarInactiveTintColor: colors.faint,
56 tabBarStyle: inConversation
57 ? { display: "none" }
58 : { backgroundColor: colors.rail, borderTopColor: colors.line, borderTopWidth: StyleSheet.hairlineWidth, elevation: 0 },
59 tabBarHideOnKeyboard: true,
60 tabBarLabelStyle: { fontSize: 11, fontWeight: "500" },
61 tabBarIconStyle: { width: 44, height: 28 },
62 }}
63 >
64 <Tabs.Screen name="index" options={{ href: null }} />
65 <Tabs.Screen name="home" options={{ title: "Home", tabBarIcon: (props) => <BarIcon icon={House} {...props} /> }} />
66 <Tabs.Screen name="work" options={{ title: "Work", tabBarIcon: (props) => <BarIcon icon={ListTodo} {...props} /> }} />
67 <Tabs.Screen
68 name="chat"
69 options={{
70 title: "Chat",
71 tabBarIcon: (props) => <BarIcon icon={MessagesSquare} {...props} />,
72 tabBarBadge: badge,
73 tabBarBadgeStyle: {
74 backgroundColor: mentions > 0 ? colors.accent : colors.lineStrong,
75 color: mentions > 0 ? colors.onAccent : colors.fg,
76 fontSize: 11,
77 },
78 }}
79 />
80 <Tabs.Screen name="notifications" options={{ title: "Notifications", tabBarIcon: (props) => <BarIcon icon={Bell} {...props} /> }} />
81 {/* Code opens from More, so it has no place in the bar. */}
82 <Tabs.Screen name="code" options={{ title: "Code", href: null }} />
83 <Tabs.Screen name="more" options={{ title: "More", tabBarIcon: (props) => <BarIcon icon={Menu} {...props} /> }} />
84 </Tabs>
85 );
86}
87
88/** A tab's icon: on the rail's accent in a 10px-rounded square when it is the tab you are in. */
89function BarIcon({ icon: Icon, focused }: { icon: LucideIcon | ComponentType<{ color: string; size: number }>; focused: boolean }) {
90 const colors = useColors();
91 return (
92 <View
93 style={{
94 width: 44,
95 height: 28,
96 borderRadius: 10,
97 alignItems: "center",
98 justifyContent: "center",
99 backgroundColor: focused ? colors.railAccent : "transparent",
100 }}
101 >
102 <Icon color={focused ? colors.fg : colors.muted} size={20} />
103 </View>
104 );
105}
106
107/**
108 * Registers the phone for notifications once signed in (asking the first
109 * time), and opens what a tapped notification is about: the thread or
110 * conversation, in its workspace.
111 */
112function PushSetup() {
113 const { client, selectWorkspace } = useSignedIn();
114 const handled = useRef<string | null>(null);
115 const response = Notifications.useLastNotificationResponse();
116
117 useEffect(() => {
118 void registerForPush(client, true).catch((error: unknown) => console.warn("g1t: registering for notifications failed", error));
119 // The system can give the app a new push token: register that one too.
120 const changed = Notifications.addPushTokenListener(() => {
121 void registerForPush(client, false).catch(() => undefined);
122 });
123 return () => changed.remove();
124 }, [client]);
125
126 useEffect(() => {
127 if (!response || response.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) return;
128 const id = response.notification.request.identifier;
129 if (handled.current === id) return;
130 handled.current = id;
131 const data = (response.notification.request.content.data ?? {}) as PushData;
132 void (async () => {
133 if (data.workspace) await selectWorkspace(data.workspace);
134 if (data.channel_id && data.thread_root) {
135 router.push({ pathname: "/chat/thread", params: { channel: data.channel_id, root: data.thread_root } });
136 } else if (data.channel_id) {
137 router.push({ pathname: "/chat/[channel]", params: { channel: data.channel_id } });
138 } else if (data.href) {
139 const route = routeFor(data.href);
140 router.push((route ?? `/open?path=${encodeURIComponent(data.href)}`) as never);
141 }
142 })();
143 }, [response, selectWorkspace]);
144
145 return null;
146}