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.

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