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.

192 linesCodeBlameRaw
1import type { ChatSidebarEntry } from "@g1t/contracts";
2import { Stack, router } from "expo-router";
3import { Hash, Lock, MessagesSquare, Search, SquarePen } from "lucide-react-native";
4import { useMemo, useState } from "react";
5import { Pressable, RefreshControl, SectionList, StyleSheet, TextInput, View } from "react-native";
6
7import { Avatar, Badge, Button, Empty, ErrorView, Loading, Screen, SectionHeader, Text } from "@/components/ui";
8import { useChatSidebar } from "@/lib/chat";
9import { useFeed } from "@/lib/feed";
10import { useSignedIn } from "@/lib/session";
11import { ago } from "@/lib/time";
12import { radius, space, useColors } from "@/theme";
13
14/**
15 * Chat: starred conversations, the channels you are in, and your direct
16 * messages, with what is unread. Counts move live with the feed
17 * (lib/feed.tsx); pull down to read everything again.
18 */
19export default function ChatList() {
20 const { workspace } = useSignedIn();
21 const sidebar = useChatSidebar();
22 const colors = useColors();
23 const [search, setSearch] = useState("");
24
25 const sections = useMemo(() => {
26 const words = search.trim().toLowerCase();
27 const entries = (sidebar.data?.entries ?? []).filter(
28 (entry) => !entry.channel.archived_at && (!words || `${entry.title} ${entry.channel.name ?? ""}`.toLowerCase().includes(words)),
29 );
30 const byRecent = (a: ChatSidebarEntry, b: ChatSidebarEntry) => (b.channel.last_message_at ?? "").localeCompare(a.channel.last_message_at ?? "");
31 return [
32 { title: "Starred", data: entries.filter((entry) => entry.starred).sort(byRecent) },
33 {
34 title: "Channels",
35 data: entries
36 .filter((entry) => !entry.starred && entry.channel.kind === "channel")
37 .sort((a, b) => (a.channel.name ?? "").localeCompare(b.channel.name ?? "")),
38 },
39 { title: "Direct messages", data: entries.filter((entry) => !entry.starred && entry.channel.kind === "dm").sort(byRecent) },
40 ].filter((section) => section.data.length > 0);
41 }, [sidebar.data, search]);
42
43 const header = (
44 <Stack.Screen
45 options={{
46 title: workspace?.name ?? workspace?.slug ?? "Chat",
47 headerRight: () => (
48 <Pressable accessibilityLabel="New message" hitSlop={10} onPress={() => router.push("/chat/new")}>
49 <SquarePen size={22} color={colors.fg} />
50 </Pressable>
51 ),
52 }}
53 />
54 );
55
56 if (!workspace) {
57 return (
58 <Screen>
59 {header}
60 <Empty icon={MessagesSquare} title="No workspace yet" body="Create or join a workspace on g1t, then come back." />
61 </Screen>
62 );
63 }
64
65 return (
66 <Screen>
67 {header}
68 {sidebar.isPending ? (
69 <Loading />
70 ) : sidebar.error ? (
71 <ErrorView error={sidebar.error} retry={() => sidebar.refetch()} />
72 ) : (
73 <SectionList
74 sections={sections}
75 keyExtractor={(entry) => entry.channel.id}
76 stickySectionHeadersEnabled={false}
77 keyboardShouldPersistTaps="handled"
78 ListHeaderComponent={
79 <View style={[styles.search, { backgroundColor: colors.raised }]}>
80 <Search size={16} color={colors.faint} />
81 <TextInput
82 value={search}
83 onChangeText={setSearch}
84 placeholder="Find a conversation"
85 placeholderTextColor={colors.faint}
86 style={{ flex: 1, color: colors.fg, fontSize: 15, paddingVertical: 8 }}
87 autoCorrect={false}
88 autoCapitalize="none"
89 />
90 </View>
91 }
92 renderSectionHeader={({ section }) => <SectionHeader title={section.title} />}
93 renderItem={({ item }) => <Conversation entry={item} />}
94 refreshControl={<RefreshControl refreshing={sidebar.isRefetching} onRefresh={() => sidebar.refetch()} tintColor={colors.accent} />}
95 ListEmptyComponent={
96 search ? (
97 <Empty title="Nothing matches" />
98 ) : (
99 <Empty
100 icon={MessagesSquare}
101 title="No conversations yet"
102 body="Message a person, or join a channel."
103 action={<Button title="New message" onPress={() => router.push("/chat/new")} />}
104 />
105 )
106 }
107 ListFooterComponent={
108 sidebar.data && sidebar.data.browsable > 0 && !search ? (
109 <Pressable onPress={() => router.push("/chat/browse")} style={styles.browse}>
110 <Hash size={16} color={colors.accent} />
111 <Text tone="accent" style={{ fontWeight: "600" }}>
112 Browse {sidebar.data.browsable} more {sidebar.data.browsable === 1 ? "channel" : "channels"}
113 </Text>
114 </Pressable>
115 ) : (
116 <View style={{ height: space.xl }} />
117 )
118 }
119 />
120 )}
121 </Screen>
122 );
123}
124
125function Conversation({ entry }: { entry: ChatSidebarEntry }) {
126 const colors = useColors();
127 const { server } = useSignedIn();
128 const { presence } = useFeed();
129 const { channel } = entry;
130 const dm = channel.kind === "dm";
131 const other = entry.others[0];
132 const loud = entry.unread > 0 && !entry.muted;
133 const online = dm && other && other.kind === "user" && presence.get(other.id) === "active";
134 const title = dm ? entry.title : (channel.name ?? entry.title);
135
136 return (
137 <Pressable
138 onPress={() => router.push({ pathname: "/chat/[channel]", params: { channel: channel.id } })}
139 style={({ pressed }) => [styles.row, { backgroundColor: pressed ? colors.raised : "transparent" }]}
140 >
141 <View>
142 {dm && other ? (
143 <Avatar name={other.display_name} uri={other.avatar ? `${server.site}/avatars/${other.avatar}` : null} agent={other.kind !== "user"} size={40} />
144 ) : (
145 <View style={[styles.icon, { backgroundColor: colors.raised }]}>
146 {channel.private ? <Lock size={18} color={colors.muted} /> : <Hash size={18} color={colors.muted} />}
147 </View>
148 )}
149 {online ? <View style={[styles.online, { backgroundColor: colors.success, borderColor: colors.bg }]} /> : null}
150 </View>
151 <View style={{ flex: 1, gap: 2 }}>
152 <View style={{ flexDirection: "row", alignItems: "center", gap: space.sm }}>
153 <Text numberOfLines={1} style={{ flex: 1, fontWeight: loud ? "800" : "500", color: entry.muted ? colors.muted : colors.fg }}>
154 {title}
155 </Text>
156 {channel.last_message_at ? (
157 <Text size="small" tone="faint">
158 {ago(channel.last_message_at)}
159 </Text>
160 ) : null}
161 </View>
162 <View style={{ flexDirection: "row", alignItems: "center", gap: space.sm }}>
163 <Text size="small" tone="muted" numberOfLines={1} style={{ flex: 1 }}>
164 {dm ? (other?.kind === "agent" ? "Deleted agent" : `@${other?.name ?? ""}`) : (channel.topic ?? (entry.muted ? "Muted" : " "))}
165 </Text>
166 {entry.mentions > 0 ? (
167 <Badge label={`@ ${entry.mentions}`} tone="accent" />
168 ) : loud ? (
169 <View style={[styles.dot, { backgroundColor: colors.accent }]} />
170 ) : null}
171 </View>
172 </View>
173 </Pressable>
174 );
175}
176
177const styles = StyleSheet.create({
178 search: {
179 flexDirection: "row",
180 alignItems: "center",
181 gap: space.sm,
182 marginHorizontal: space.lg,
183 marginTop: space.md,
184 paddingHorizontal: space.md,
185 borderRadius: radius.md,
186 },
187 row: { flexDirection: "row", alignItems: "center", gap: space.md, paddingHorizontal: space.lg, paddingVertical: 10 },
188 icon: { width: 40, height: 40, borderRadius: radius.md, alignItems: "center", justifyContent: "center" },
189 online: { position: "absolute", right: -1, bottom: -1, width: 12, height: 12, borderRadius: 6, borderWidth: 2 },
190 dot: { width: 9, height: 9, borderRadius: 5 },
191 browse: { flexDirection: "row", alignItems: "center", gap: space.sm, padding: space.lg },
192});