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.

128 linesCodeBlameRaw
1import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2import Constants from "expo-constants";
3import { hasCodeAccess } from "@g1t/contracts";
4import { Stack, router } from "expo-router";
5import * as WebBrowser from "expo-web-browser";
6import { Bell, BellRing, Check, Code2, ExternalLink, LogOut } from "lucide-react-native";
7import { Linking, View } from "react-native";
8
9import { Avatar, Badge, Button, Row, Screen, SectionHeader, Text } from "@/components/ui";
10import { pushState, registerForPush, sendTestPush } from "@/lib/push";
11import { isHosted, siteUrl } from "@/lib/server";
12import { useSignedIn } from "@/lib/session";
13import { space, useColors } from "@/theme";
14
15/** More: Code, notifications on this phone, your workspaces, how you signed in, and signing out. */
16export default function More() {
17 const { me, workspace, server, client, selectWorkspace, signOut } = useSignedIn();
18 const code = !workspace || hasCodeAccess(workspace);
19 const colors = useColors();
20 const queries = useQueryClient();
21 const name = me.display_username ?? me.username;
22 const oauth = client.credentials.kind === "oauth";
23 const push = useQuery({ queryKey: ["push-state", server.site], queryFn: () => pushState(server) });
24 const test = useMutation({ mutationFn: () => sendTestPush(client) });
25
26 const pushRow = {
27 unsupported: { label: "Not here", detail: "Notifications need a build of the app on a phone: not Expo Go on Android, nor a simulator." },
28 off: { label: "Off", detail: `${new URL(server.site).host} sends no notifications to phones. Chat still updates while the app is open.` },
29 denied: { label: "Off", detail: "Turned off for g1t in your phone's settings. Tap to open them." },
30 undetermined: { label: "Off", detail: "Tap to get notified of messages to you, mentions and replies." },
31 granted: { label: "On", detail: "Messages to you, mentions and replies, when you're not at g1t elsewhere." },
32 }[push.data ?? "undetermined"];
33
34 return (
35 <Screen scroll style={{ paddingHorizontal: 0 }}>
36 <Stack.Screen options={{ title: "More" }} />
37 <View style={{ flexDirection: "row", gap: space.md, alignItems: "center", paddingHorizontal: space.lg }}>
38 <Avatar name={name} uri={me.avatar ? `${server.site}/avatars/${me.avatar}` : null} size={48} />
39 <View style={{ flex: 1 }}>
40 <Text size="heading">{name}</Text>
41 <Text size="small" tone="muted">
42 @{me.username} · {isHosted(server) ? "g1t.sh" : new URL(server.site).host}
43 </Text>
44 </View>
45 </View>
46
47 {code ? (
48 <View>
49 <Row
50 leading={<Code2 size={22} color={colors.accent} />}
51 title="Code"
52 subtitle="Repositories, their pull requests and changes"
53 onPress={() => router.push("/code")}
54 />
55 </View>
56 ) : null}
57
58 <View>
59 <Row
60 leading={<Bell size={22} color={colors.accent} />}
61 title="Notifications on this phone"
62 subtitle={pushRow.detail}
63 trailing={<Badge label={pushRow.label} tone={push.data === "granted" ? "success" : "neutral"} />}
64 chevron={false}
65 onPress={
66 push.data === "denied"
67 ? () => void Linking.openSettings()
68 : push.data === "undetermined"
69 ? () => void registerForPush(client, true).finally(() => queries.invalidateQueries({ queryKey: ["push-state", server.site] }))
70 : undefined
71 }
72 />
73 {push.data === "granted" ? (
74 <Row
75 leading={<BellRing size={22} color={colors.accent} />}
76 title="Send a test notification"
77 subtitle={
78 test.isPending
79 ? "Sending…"
80 : test.error
81 ? test.error.message
82 : test.data !== undefined
83 ? test.data > 0
84 ? `Sent to ${test.data} ${test.data === 1 ? "place" : "places"}: this phone, and any browser with notifications on.`
85 : "Nothing was sent: g1t has no phone or browser for you. Sign out and in again to register this phone."
86 : "To check this phone gets them. It arrives in a few seconds, even with the app open; if it never does, see the setup guide's troubleshooting."
87 }
88 chevron={false}
89 onPress={test.isPending ? undefined : () => test.mutate()}
90 />
91 ) : null}
92 </View>
93
94 <View>
95 <SectionHeader title="Workspaces" />
96 {(me.workspaces ?? []).map((one) => (
97 <Row
98 key={one.slug}
99 leading={<Avatar name={one.name ?? one.slug} uri={one.avatar ? `${server.site}/avatars/${one.avatar}` : null} />}
100 title={one.name ?? one.slug}
101 subtitle={`${one.slug} · ${one.role}`}
102 trailing={one.slug === workspace?.slug ? <Check size={18} color={colors.accent} /> : null}
103 chevron={false}
104 onPress={() => selectWorkspace(one.slug)}
105 />
106 ))}
107 </View>
108
109 <View>
110 <SectionHeader title="Signed in" />
111 <Row
112 title={oauth ? "With g1t" : "With an access token"}
113 subtitle={oauth ? "End or narrow the app's access in Settings → Connected applications." : "The token you pasted. Delete it in Settings → Access tokens to end it."}
114 trailing={<ExternalLink size={16} color={colors.faint} />}
115 chevron={false}
116 onPress={() => WebBrowser.openBrowserAsync(siteUrl(server, oauth ? "/settings/applications" : "/settings/tokens"))}
117 />
118 </View>
119
120 <View style={{ paddingHorizontal: space.lg, gap: space.md }}>
121 <Button title="Sign out" variant="danger" icon={LogOut} onPress={signOut} />
122 <Text size="small" tone="faint" style={{ textAlign: "center" }}>
123 g1t {Constants.expoConfig?.version ?? ""}
124 </Text>
125 </View>
126 </Screen>
127 );
128}