| 1 | import type { ChatSidebarEntry } from "@g1t/contracts"; |
| 2 | import { Stack, router } from "expo-router"; |
| 3 | import { Hash, Lock, MessagesSquare, Search, SquarePen } from "lucide-react-native"; |
| 4 | import { useMemo, useState } from "react"; |
| 5 | import { Pressable, RefreshControl, SectionList, StyleSheet, TextInput, View } from "react-native"; |
| 6 | |
| 7 | import { Avatar, Badge, Button, Empty, ErrorView, Loading, Screen, SectionHeader, Text } from "@/components/ui"; |
| 8 | import { useChatSidebar } from "@/lib/chat"; |
| 9 | import { useFeed } from "@/lib/feed"; |
| 10 | import { useSignedIn } from "@/lib/session"; |
| 11 | import { ago } from "@/lib/time"; |
| 12 | import { 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 | */ |
| 19 | export 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 | |
| 115 | function 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 | |
| 163 | const 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 | }); |