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