| 1 | import type { MemberProfile } from "@g1t/contracts"; |
| 2 | import { useQuery } from "@tanstack/react-query"; |
| 3 | import { Stack, router } from "expo-router"; |
| 4 | import { Check, Hash, Search } from "lucide-react-native"; |
| 5 | import { useMemo, useState } from "react"; |
| 6 | import { FlatList, Pressable, StyleSheet, TextInput, View } from "react-native"; |
| 7 | |
| 8 | import { PromptSheet } from "@/components/chat/sheet"; |
| 9 | import { Avatar, Button, Loading, Row, Screen, SectionHeader, Text } from "@/components/ui"; |
| 10 | import { useChatSidebar, useStartConversation } from "@/lib/chat"; |
| 11 | import { useSignedIn } from "@/lib/session"; |
| 12 | import { radius, space, useColors } from "@/theme"; |
| 13 | |
| 14 | type 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 | */ |
| 22 | export 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 | |
| 166 | const 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 | }); |