Skip to content
170 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 { title: "Channels", data: entries.filter((entry) => !entry.starred && entry.channel.kind === "channel").sort((a, b) => (a.channel.name ?? "").localeCompare(b.channel.name ?? "")) },
34 { title: "Direct messages", data: entries.filter((entry) => !entry.starred && entry.channel.kind === "dm").sort(byRecent) },
35 ].filter((section) => section.data.length > 0);
36 }, [sidebar.data, search]);
37
38 const header = (
39 <Stack.Screen
40 options={{
41 title: workspace?.name ?? workspace?.slug ?? "Chat",
42 headerRight: () => (
43 <Pressable accessibilityLabel="New message" hitSlop={10} onPress={() => router.push("/chat/new")}>
44 <SquarePen size={22} color={colors.fg} />
45 </Pressable>
46 ),
47 }}
48 />
49 );
50
51 if (!workspace) {
52 return (
53 <Screen>
54 {header}
55 <Empty icon={MessagesSquare} title="No workspace yet" body="Create or join a workspace on g1t, then come back." />
56 </Screen>
57 );
58 }
59
60 return (
61 <Screen>
62 {header}
63 {sidebar.isPending ? (
64 <Loading />
65 ) : sidebar.error ? (
66 <ErrorView error={sidebar.error} retry={() => sidebar.refetch()} />
67 ) : (
68 <SectionList
69 sections={sections}
70 keyExtractor={(entry) => entry.channel.id}
71 stickySectionHeadersEnabled={false}
72 keyboardShouldPersistTaps="handled"
73 ListHeaderComponent={
74 <View style={[styles.search, { backgroundColor: colors.raised }]}>
75 <Search size={16} color={colors.faint} />
76 <TextInput
77 value={search}
78 onChangeText={setSearch}
79 placeholder="Find a conversation"
80 placeholderTextColor={colors.faint}
81 style={{ flex: 1, color: colors.fg, fontSize: 15, paddingVertical: 8 }}
82 autoCorrect={false}
83 autoCapitalize="none"
84 />
85 </View>
86 }
87 renderSectionHeader={({ section }) => <SectionHeader title={section.title} />}
88 renderItem={({ item }) => <Conversation entry={item} />}
89 refreshControl={<RefreshControl refreshing={sidebar.isRefetching} onRefresh={() => sidebar.refetch()} tintColor={colors.accent} />}
90 ListEmptyComponent={
91 search ? (
92 <Empty title="Nothing matches" />
93 ) : (
94 <Empty icon={MessagesSquare} title="No conversations yet" body="Message a person or an agent, or join a channel." action={<Button title="New message" onPress={() => router.push("/chat/new")} />} />
95 )
96 }
97 ListFooterComponent={
98 sidebar.data && sidebar.data.browsable > 0 && !search ? (
99 <Pressable onPress={() => router.push("/chat/browse")} style={styles.browse}>
100 <Hash size={16} color={colors.accent} />
101 <Text tone="accent" style={{ fontWeight: "600" }}>
102 Browse {sidebar.data.browsable} more {sidebar.data.browsable === 1 ? "channel" : "channels"}
103 </Text>
104 </Pressable>
105 ) : (
106 <View style={{ height: space.xl }} />
107 )
108 }
109 />
110 )}
111 </Screen>
112 );
113}
114
115function Conversation({ entry }: { entry: ChatSidebarEntry }) {
116 const colors = useColors();
117 const { server } = useSignedIn();
118 const { presence } = useFeed();
119 const { channel } = entry;
120 const dm = channel.kind === "dm";
121 const other = entry.others[0];
122 const loud = entry.unread > 0 && !entry.muted;
123 const online = dm && other && other.kind === "user" && presence.get(other.id) === "active";
124 const title = dm ? entry.title : (channel.name ?? entry.title);
125
126 return (
127 <Pressable
128 onPress={() => router.push({ pathname: "/chat/[channel]", params: { channel: channel.id } })}
129 style={({ pressed }) => [styles.row, { backgroundColor: pressed ? colors.raised : "transparent" }]}
130 >
131 <View>
132 {dm && other ? (
133 <Avatar name={other.display_name} uri={other.avatar ? `${server.site}/avatars/${other.avatar}` : null} agent={other.kind === "agent"} size={40} />
134 ) : (
135 <View style={[styles.icon, { backgroundColor: colors.raised }]}>
136 {channel.private ? <Lock size={18} color={colors.muted} /> : <Hash size={18} color={colors.muted} />}
137 </View>
138 )}
139 {online ? <View style={[styles.online, { backgroundColor: colors.success, borderColor: colors.bg }]} /> : null}
140 </View>
141 <View style={{ flex: 1, gap: 2 }}>
142 <View style={{ flexDirection: "row", alignItems: "center", gap: space.sm }}>
143 <Text numberOfLines={1} style={{ flex: 1, fontWeight: loud ? "800" : "500", color: entry.muted ? colors.muted : colors.fg }}>
144 {title}
145 </Text>
146 {channel.last_message_at ? (
147 <Text size="small" tone="faint">
148 {ago(channel.last_message_at)}
149 </Text>
150 ) : null}
151 </View>
152 <View style={{ flexDirection: "row", alignItems: "center", gap: space.sm }}>
153 <Text size="small" tone="muted" numberOfLines={1} style={{ flex: 1 }}>
154 {dm ? (other?.kind === "agent" ? (other.title ?? other.role ?? "Agent") : `@${other?.name ?? ""}`) : (channel.topic ?? (entry.muted ? "Muted" : " "))}
155 </Text>
156 {entry.mentions > 0 ? <Badge label={`@ ${entry.mentions}`} tone="accent" /> : loud ? <View style={[styles.dot, { backgroundColor: colors.accent }]} /> : null}
157 </View>
158 </View>
159 </Pressable>
160 );
161}
162
163const styles = StyleSheet.create({
164 search: { flexDirection: "row", alignItems: "center", gap: space.sm, marginHorizontal: space.lg, marginTop: space.md, paddingHorizontal: space.md, borderRadius: radius.md },
165 row: { flexDirection: "row", alignItems: "center", gap: space.md, paddingHorizontal: space.lg, paddingVertical: 10 },
166 icon: { width: 40, height: 40, borderRadius: radius.md, alignItems: "center", justifyContent: "center" },
167 online: { position: "absolute", right: -1, bottom: -1, width: 12, height: 12, borderRadius: 6, borderWidth: 2 },
168 dot: { width: 9, height: 9, borderRadius: 5 },
169 browse: { flexDirection: "row", alignItems: "center", gap: space.sm, padding: space.lg },
170});