Skip to content
269 linesCodeBlameRaw
1import { Image } from "expo-image";
2import type { LucideIcon } from "lucide-react-native";
3import { ChevronRight } from "lucide-react-native";
4import type { ReactNode } from "react";
5import {
6 ActivityIndicator,
7 Pressable,
8 type PressableProps,
9 ScrollView,
10 StyleSheet,
11 Text as NativeText,
12 type TextProps,
13 TextInput,
14 type TextInputProps,
15 View,
16 type ViewProps,
17} from "react-native";
18import { SafeAreaView } from "react-native-safe-area-context";
19
20import { ApiError } from "@/lib/client";
21import { fonts, radius, space, useColors } from "@/theme";
22
23/**
24 * The app's building blocks, themed with g1t's tokens. As on the site
25 * (apps/web/app/components/ui), a screen is built from these, and a look
26 * changes through a component's variant, not a style at the call site.
27 */
28
29type Tone = "default" | "muted" | "faint" | "accent" | "danger" | "success";
30type Size = "title" | "heading" | "body" | "small" | "mono";
31
32const SIZES: Record<Size, { fontSize: number; lineHeight: number; fontWeight?: "600" | "700" }> = {
33 title: { fontSize: 26, lineHeight: 32, fontWeight: "700" },
34 heading: { fontSize: 17, lineHeight: 22, fontWeight: "600" },
35 body: { fontSize: 15, lineHeight: 21 },
36 small: { fontSize: 13, lineHeight: 18 },
37 mono: { fontSize: 12, lineHeight: 17 },
38};
39
40export function Text({ size = "body", tone = "default", style, ...props }: TextProps & { size?: Size; tone?: Tone }) {
41 const colors = useColors();
42 const color = { default: colors.fg, muted: colors.muted, faint: colors.faint, accent: colors.accent, danger: colors.danger, success: colors.success }[tone];
43 return <NativeText style={[SIZES[size], size === "mono" && { fontFamily: fonts.mono }, { color }, style]} {...props} />;
44}
45
46/** A screen's background, inside the safe area when it has no header above it. */
47export function Screen({ children, scroll, safe, style, ...props }: ViewProps & { scroll?: boolean; safe?: boolean }) {
48 const colors = useColors();
49 const body = scroll ? (
50 <ScrollView contentContainerStyle={[styles.padded, style]} keyboardShouldPersistTaps="handled">
51 {children}
52 </ScrollView>
53 ) : (
54 <View style={[{ flex: 1 }, style]} {...props}>
55 {children}
56 </View>
57 );
58 return safe ? (
59 <SafeAreaView style={{ flex: 1, backgroundColor: colors.bg }}>{body}</SafeAreaView>
60 ) : (
61 <View style={{ flex: 1, backgroundColor: colors.bg }}>{body}</View>
62 );
63}
64
65export function Button({
66 title,
67 variant = "primary",
68 busy,
69 disabled,
70 icon: Icon,
71 ...props
72}: PressableProps & { title: string; variant?: "primary" | "secondary" | "danger"; busy?: boolean; icon?: LucideIcon }) {
73 const colors = useColors();
74 const look = {
75 primary: { bg: colors.accent, fg: colors.onAccent, border: colors.accent },
76 secondary: { bg: colors.surface, fg: colors.fg, border: colors.lineStrong },
77 danger: { bg: colors.surface, fg: colors.danger, border: colors.lineStrong },
78 }[variant];
79 const off = disabled || busy;
80 return (
81 <Pressable
82 accessibilityRole="button"
83 disabled={off}
84 style={({ pressed }) => [styles.button, { backgroundColor: look.bg, borderColor: look.border, opacity: off ? 0.55 : pressed ? 0.8 : 1 }]}
85 {...props}
86 >
87 {busy ? <ActivityIndicator color={look.fg} /> : Icon ? <Icon size={18} color={look.fg} /> : null}
88 <Text style={{ color: look.fg, fontWeight: "600" }}>{title}</Text>
89 </Pressable>
90 );
91}
92
93export function Field({ label, hint, ...props }: TextInputProps & { label: string; hint?: string }) {
94 const colors = useColors();
95 return (
96 <View style={{ gap: space.xs }}>
97 <Text size="small" tone="muted">
98 {label}
99 </Text>
100 <TextInput
101 placeholderTextColor={colors.faint}
102 autoCapitalize="none"
103 autoCorrect={false}
104 style={[styles.input, { color: colors.fg, backgroundColor: colors.surface, borderColor: colors.lineStrong }]}
105 {...props}
106 />
107 {hint ? (
108 <Text size="small" tone="faint">
109 {hint}
110 </Text>
111 ) : null}
112 </View>
113 );
114}
115
116/** A row in a list: what it is, a line under it, and what is at its end. */
117export function Row({
118 title,
119 subtitle,
120 leading,
121 trailing,
122 chevron = true,
123 onPress,
124}: {
125 title: ReactNode;
126 subtitle?: ReactNode;
127 leading?: ReactNode;
128 trailing?: ReactNode;
129 chevron?: boolean;
130 onPress?: () => void;
131}) {
132 const colors = useColors();
133 return (
134 <Pressable
135 onPress={onPress}
136 disabled={!onPress}
137 style={({ pressed }) => [styles.row, { borderBottomColor: colors.line, backgroundColor: pressed ? colors.raised : "transparent" }]}
138 >
139 {leading}
140 <View style={{ flex: 1, gap: 2 }}>
141 {typeof title === "string" ? (
142 <Text numberOfLines={1} style={{ fontWeight: "600" }}>
143 {title}
144 </Text>
145 ) : (
146 title
147 )}
148 {typeof subtitle === "string" ? (
149 <Text size="small" tone="muted" numberOfLines={2}>
150 {subtitle}
151 </Text>
152 ) : (
153 subtitle
154 )}
155 </View>
156 {trailing}
157 {onPress && chevron ? <ChevronRight size={18} color={colors.faint} /> : null}
158 </Pressable>
159 );
160}
161
162export function SectionHeader({ title }: { title: string }) {
163 const colors = useColors();
164 return (
165 <View style={[styles.sectionHeader, { backgroundColor: colors.bg }]}>
166 <Text size="small" tone="muted" style={{ fontWeight: "600", textTransform: "uppercase", letterSpacing: 0.6 }}>
167 {title}
168 </Text>
169 </View>
170 );
171}
172
173type BadgeTone = "neutral" | "accent" | "success" | "danger" | "merged" | "warn";
174
175export function Badge({ label, tone = "neutral" }: { label: string; tone?: BadgeTone }) {
176 const colors = useColors();
177 const fg = { neutral: colors.muted, accent: colors.accent, success: colors.success, danger: colors.danger, merged: colors.merged, warn: colors.warn }[tone];
178 return (
179 <View style={[styles.badge, { borderColor: fg }]}>
180 <Text size="small" style={{ color: fg, fontWeight: "600" }}>
181 {label}
182 </Text>
183 </View>
184 );
185}
186
187/**
188 * A person's or an agent's picture: the uploaded one when there is one,
189 * else their initial. Agents carry a marker, as everywhere on g1t. The
190 * site draws an agent's own bot face (components/agent-face.tsx); bringing
191 * that here is to come.
192 */
193export function Avatar({ name, uri, agent, size = 32 }: { name: string; uri?: string | null; agent?: boolean; size?: number }) {
194 const colors = useColors();
195 const corner = agent ? radius.sm : size / 2;
196 return (
197 <View accessibilityLabel={agent ? `${name}, agent` : name} style={{ width: size, height: size }}>
198 {uri ? (
199 <Image source={{ uri }} style={{ width: size, height: size, borderRadius: corner }} />
200 ) : (
201 <View style={{ width: size, height: size, borderRadius: corner, backgroundColor: colors.accentLow, alignItems: "center", justifyContent: "center" }}>
202 <Text style={{ color: colors.accent, fontWeight: "700", fontSize: size * 0.42, lineHeight: size * 0.5 }}>{name.slice(0, 1).toUpperCase()}</Text>
203 </View>
204 )}
205 {agent ? <View style={[styles.agentMarker, { backgroundColor: colors.accent, borderColor: colors.bg }]} /> : null}
206 </View>
207 );
208}
209
210export function Loading() {
211 const colors = useColors();
212 return (
213 <View style={styles.center}>
214 <ActivityIndicator color={colors.accent} />
215 </View>
216 );
217}
218
219export function Empty({ icon: Icon, title, body, action }: { icon?: LucideIcon; title: string; body?: string; action?: ReactNode }) {
220 const colors = useColors();
221 return (
222 <View style={[styles.center, { gap: space.sm, padding: space.xl }]}>
223 {Icon ? <Icon size={28} color={colors.faint} /> : null}
224 <Text size="heading" style={{ textAlign: "center" }}>
225 {title}
226 </Text>
227 {body ? (
228 <Text tone="muted" style={{ textAlign: "center" }}>
229 {body}
230 </Text>
231 ) : null}
232 {action}
233 </View>
234 );
235}
236
237/** What went wrong, in words, with a way to try again. */
238export function ErrorView({ error, retry }: { error: unknown; retry?: () => void }) {
239 const message = error instanceof Error ? error.message : "Something went wrong.";
240 const title = error instanceof ApiError && error.code === "website_refused" ? "Chat needs another sign-in" : "Could not load this";
241 return <Empty title={title} body={message} action={retry ? <Button title="Try again" variant="secondary" onPress={retry} /> : null} />;
242}
243
244const styles = StyleSheet.create({
245 padded: { padding: space.lg, gap: space.lg },
246 button: {
247 minHeight: 46,
248 borderRadius: radius.md,
249 borderWidth: 1,
250 paddingHorizontal: space.lg,
251 flexDirection: "row",
252 alignItems: "center",
253 justifyContent: "center",
254 gap: space.sm,
255 },
256 input: { minHeight: 46, borderRadius: radius.md, borderWidth: 1, paddingHorizontal: space.md, fontSize: 15 },
257 row: {
258 flexDirection: "row",
259 alignItems: "center",
260 gap: space.md,
261 paddingHorizontal: space.lg,
262 paddingVertical: space.md,
263 borderBottomWidth: StyleSheet.hairlineWidth,
264 },
265 sectionHeader: { paddingHorizontal: space.lg, paddingTop: space.lg, paddingBottom: space.sm },
266 badge: { borderWidth: 1, borderRadius: radius.full, paddingHorizontal: space.sm, paddingVertical: 1 },
267 agentMarker: { position: "absolute", right: -2, bottom: -2, width: 10, height: 10, borderRadius: 3, borderWidth: 2 },
268 center: { flex: 1, alignItems: "center", justifyContent: "center" },
269});