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.

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