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.

g1t.sh
155 linesCodeBlameRaw
1import { useQuery } from "@tanstack/react-query";
2import { Stack, router } from "expo-router";
3import { Check, Hash, Search } from "lucide-react-native";
4import { useMemo, useState } from "react";
5import { FlatList, Pressable, StyleSheet, TextInput, View } from "react-native";
6
7import { PromptSheet } from "@/components/chat/sheet";
8import { Avatar, Button, Loading, Row, Screen, SectionHeader, Text } from "@/components/ui";
9import { useStartConversation } from "@/lib/chat";
10import { useSignedIn } from "@/lib/session";
11import { radius, space, useColors } from "@/theme";
12
13type Person = { key: string; name: string; handle: string; avatar: string | null };
14
15/**
16 * A new conversation: pick people for a direct message (one, or a
17 * group), or make a channel. People come from the workspace's members
18 * (`GET /workspaces/<ws>/members`).
19 */
20export default function NewConversation() {
21 const { client, workspace, me, server } = useSignedIn();
22 const slug = workspace?.slug ?? "";
23 const colors = useColors();
24 const start = useStartConversation();
25 const [search, setSearch] = useState("");
26 const [picked, setPicked] = useState<Person[]>([]);
27 const [making, setMaking] = useState(false);
28 const [error, setError] = useState<string | null>(null);
29
30 const members = useQuery({
31 queryKey: ["workspace", slug, "members"],
32 queryFn: () =>
33 client.api<{ username: string; display_username?: string; name?: string | null; avatar?: string | null }[]>(`/workspaces/${encodeURIComponent(slug)}/members`),
34 enabled: !!slug,
35 });
36
37 const everyone = useMemo(() => {
38 const people: Person[] = (members.data ?? [])
39 .filter((member) => member.username.toLowerCase() !== me.username.toLowerCase())
40 .map((member) => ({
41 key: `user:${member.username.toLowerCase()}`,
42 name: member.name || member.display_username || member.username,
43 handle: member.display_username ?? member.username,
44 avatar: member.avatar ?? null,
45 }));
46 const words = search.trim().toLowerCase();
47 return people.filter((one) => !words || `${one.name} ${one.handle}`.toLowerCase().includes(words));
48 }, [members.data, me.username, search]);
49
50 const toggle = (person: Person) =>
51 setPicked((now) => (now.some((one) => one.key === person.key) ? now.filter((one) => one.key !== person.key) : [...now, person]));
52
53 const open = () => {
54 setError(null);
55 start.dm.mutate(
56 picked.map((one) => one.key),
57 {
58 onSuccess: ({ channel }) => router.replace({ pathname: "/chat/[channel]", params: { channel: channel.id } }),
59 onError: (failure) => setError(failure.message),
60 },
61 );
62 };
63
64 return (
65 <Screen>
66 <Stack.Screen options={{ title: "New message", presentation: "modal" }} />
67 <View style={[styles.search, { backgroundColor: colors.raised }]}>
68 <Search size={16} color={colors.faint} />
69 <TextInput
70 autoFocus
71 value={search}
72 onChangeText={setSearch}
73 placeholder="Find a person"
74 placeholderTextColor={colors.faint}
75 style={{ flex: 1, color: colors.fg, fontSize: 15, paddingVertical: 8 }}
76 autoCorrect={false}
77 autoCapitalize="none"
78 />
79 </View>
80 {picked.length ? (
81 <Text size="small" tone="muted" style={{ paddingHorizontal: space.lg, paddingTop: space.sm }}>
82 To {picked.map((one) => one.name).join(", ")}
83 </Text>
84 ) : null}
85 {members.isPending ? (
86 <Loading />
87 ) : (
88 <FlatList
89 data={everyone}
90 keyExtractor={(one) => one.key}
91 keyboardShouldPersistTaps="handled"
92 ListHeaderComponent={
93 <>
94 <Row leading={<Hash size={20} color={colors.accent} />} title="Make a channel" subtitle="A conversation for a team or a topic" onPress={() => setMaking(true)} />
95 <SectionHeader title="People" />
96 </>
97 }
98 renderItem={({ item }) => {
99 const on = picked.some((one) => one.key === item.key);
100 return (
101 <Pressable onPress={() => toggle(item)} style={({ pressed }) => [styles.person, { backgroundColor: pressed ? colors.raised : "transparent" }]}>
102 <Avatar name={item.name} uri={item.avatar ? `${server.site}/avatars/${item.avatar}` : null} size={36} />
103 <View style={{ flex: 1 }}>
104 <Text style={{ fontWeight: "600" }} numberOfLines={1}>
105 {item.name}
106 </Text>
107 <Text size="small" tone="muted" numberOfLines={1}>
108 @{item.handle}
109 </Text>
110 </View>
111 <View style={[styles.check, { borderColor: on ? colors.accent : colors.lineStrong, backgroundColor: on ? colors.accent : "transparent" }]}>
112 {on ? <Check size={14} color={colors.onAccent} strokeWidth={3} /> : null}
113 </View>
114 </Pressable>
115 );
116 }}
117 />
118 )}
119 {error ? (
120 <Text tone="danger" style={{ paddingHorizontal: space.lg }}>
121 {error}
122 </Text>
123 ) : null}
124 {picked.length ? (
125 <View style={{ padding: space.lg }}>
126 <Button title={picked.length === 1 ? `Message ${picked[0].name}` : `Message ${picked.length}`} busy={start.dm.isPending} onPress={open} />
127 </View>
128 ) : null}
129 <PromptSheet
130 visible={making}
131 title="Make a channel"
132 label="Lowercase words joined by dashes, like design-reviews."
133 placeholder="channel-name"
134 confirm="Make it"
135 onCancel={() => setMaking(false)}
136 onDone={(name) => {
137 setMaking(false);
138 start.create.mutate(
139 { name },
140 {
141 onSuccess: ({ channel }) => router.replace({ pathname: "/chat/[channel]", params: { channel: channel.id } }),
142 onError: (failure) => setError(failure.message),
143 },
144 );
145 }}
146 />
147 </Screen>
148 );
149}
150
151const styles = StyleSheet.create({
152 search: { flexDirection: "row", alignItems: "center", gap: space.sm, marginHorizontal: space.lg, marginTop: space.md, paddingHorizontal: space.md, borderRadius: radius.md },
153 person: { flexDirection: "row", alignItems: "center", gap: space.md, paddingHorizontal: space.lg, paddingVertical: 10 },
154 check: { width: 22, height: 22, borderRadius: 11, borderWidth: 2, alignItems: "center", justifyContent: "center" },
155});