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.

294 linesCodeBlameRaw
1import type { ChatMessage } from "@g1t/contracts";
2import { useQueryClient } from "@tanstack/react-query";
3import * as Clipboard from "expo-clipboard";
4import { router, useFocusEffect } from "expo-router";
5import { useHeaderHeight } from "expo-router/react-navigation";
6import { ArrowDown, Copy, MessageSquareReply, Pencil, Trash2 } from "lucide-react-native";
7import { useCallback, useEffect, useMemo, useRef, useState } from "react";
8import { ActivityIndicator, Alert, FlatList, KeyboardAvoidingView, Pressable, StyleSheet, View } from "react-native";
9
10import { Composer } from "@/components/chat/composer";
11import { Message, runsOn } from "@/components/chat/message";
12import { PromptSheet, Sheet, SheetAction } from "@/components/chat/sheet";
13import { ErrorView, Loading, Text } from "@/components/ui";
14import { type Shown, markRead, useChannelLive, useChatActions, useMessages, useSend } from "@/lib/chat";
15import { useSignedIn } from "@/lib/session";
16import { viewing } from "@/lib/viewing";
17import { radius, space, useColors } from "@/theme";
18
19const QUICK = ["👍", "❤️", "😂", "🎉", "👀", "✅"];
20
21type Row = { kind: "message"; message: Shown; head: boolean } | { kind: "day"; key: string; label: string };
22
23/**
24 * A conversation, or one thread of it: messages newest at the bottom, live
25 * while open, read as they arrive, and a box to write in. Long-press a
26 * message to react, reply in a thread, copy, or change or delete your own.
27 */
28export function Conversation({ channelId, thread, title }: { channelId: string; thread: string | null; title: string }) {
29 const colors = useColors();
30 const queries = useQueryClient();
31 const { client, workspace, me } = useSignedIn();
32 const slug = workspace?.slug ?? "";
33 const messages = useMessages(channelId, thread);
34 const live = useChannelLive(channelId);
35 const send = useSend(channelId, thread);
36 const actions = useChatActions(channelId);
37 const [picked, setPicked] = useState<Shown | null>(null);
38 const [editing, setEditing] = useState<Shown | null>(null);
39 const [scrolledUp, setScrolledUp] = useState(false);
40 const list = useRef<FlatList<Row>>(null);
41 const focused = useRef(false);
42 const headerHeight = useHeaderHeight();
43
44 // While on screen, this conversation's banners and pushes are not shown.
45 useFocusEffect(
46 useCallback(() => {
47 focused.current = true;
48 viewing.channelId = channelId;
49 viewing.threadRoot = thread;
50 return () => {
51 focused.current = false;
52 if (viewing.channelId === channelId) {
53 viewing.channelId = null;
54 viewing.threadRoot = null;
55 }
56 };
57 }, [channelId, thread]),
58 );
59
60 const all = useMemo(() => {
61 const pages = messages.data?.pages ?? [];
62 // Oldest first: the last page read is the oldest.
63 const ordered = [...pages].reverse().flatMap((page) => page.messages as Shown[]);
64 // A thread's first page carries the message it is under.
65 const root = thread ? (pages[0]?.root ?? null) : null;
66 const shown = ordered.filter((message) => !message.deleted_at && message.id !== root?.id);
67 return root ? [root as Shown, ...shown] : shown;
68 }, [messages.data, thread]);
69
70 // Rows newest first, for a list drawn from the bottom up, with the day between days.
71 const rows = useMemo(() => {
72 const out: Row[] = [];
73 let day = "";
74 all.forEach((message, index) => {
75 const date = new Date(message.created_at);
76 const label = dayLabel(date);
77 if (label !== day) {
78 out.push({ kind: "day", key: `day-${label}-${index}`, label });
79 day = label;
80 }
81 out.push({ kind: "message", message, head: label !== dayLabel(new Date(all[index - 1]?.created_at ?? 0)) || !runsOn(all[index - 1], message) });
82 });
83 return out.reverse();
84 }, [all]);
85
86 // Read up to the newest while it is on screen.
87 const newest = all.filter((message) => !message.local).at(-1)?.id;
88 useEffect(() => {
89 if (slug && newest && focused.current && !thread) void markRead(client, queries, slug, channelId, newest);
90 }, [client, queries, slug, channelId, newest, thread]);
91
92 const onReact = useCallback((message: ChatMessage, emoji: string, add: boolean) => actions.react.mutate({ message, emoji, add }), [actions.react]);
93 const onRetry = useCallback((message: Shown) => send(message.body, message), [send]);
94
95 const mine = picked?.author.kind === "user" && picked.author.id === me.id;
96 const typingLine = typingText(live.typing.map((member) => member.display_name));
97
98 return (
99 // The keyboard pushes the composer up and the list shrinks from the top, on
100 // Android too: apps draw edge to edge there, so the window no longer resizes
101 // for it. The list is drawn from the bottom, so the newest stays in view.
102 <KeyboardAvoidingView style={{ flex: 1 }} behavior="padding" keyboardVerticalOffset={headerHeight}>
103 <View style={{ flex: 1 }}>
104 {messages.isPending ? (
105 <Loading />
106 ) : messages.error ? (
107 <ErrorView error={messages.error} retry={() => messages.refetch()} />
108 ) : (
109 <FlatList
110 ref={list}
111 inverted
112 data={rows}
113 keyExtractor={(row) => (row.kind === "day" ? row.key : row.message.id)}
114 renderItem={({ item }) =>
115 item.kind === "day" ? (
116 <View style={styles.day}>
117 <View style={[styles.dayLine, { backgroundColor: colors.line }]} />
118 <Text size="small" tone="faint" style={{ fontWeight: "600" }}>
119 {item.label}
120 </Text>
121 <View style={[styles.dayLine, { backgroundColor: colors.line }]} />
122 </View>
123 ) : (
124 <Message message={item.message} head={item.head} inThread={!!thread} onLongPress={setPicked} onReact={onReact} onRetry={onRetry} />
125 )
126 }
127 onEndReached={() => messages.hasNextPage && !messages.isFetchingNextPage && messages.fetchNextPage()}
128 onEndReachedThreshold={0.6}
129 onScroll={(event) => setScrolledUp(event.nativeEvent.contentOffset.y > 600)}
130 scrollEventThrottle={100}
131 ListHeaderComponent={<View style={{ height: space.sm }} />}
132 ListFooterComponent={
133 messages.isFetchingNextPage ? (
134 <ActivityIndicator style={{ margin: space.lg }} color={colors.accent} />
135 ) : !messages.hasNextPage ? (
136 <Text size="small" tone="faint" style={{ textAlign: "center", padding: space.xl }}>
137 {thread ? "The start of this thread." : `The start of ${title}.`}
138 </Text>
139 ) : null
140 }
141 keyboardDismissMode="interactive"
142 keyboardShouldPersistTaps="handled"
143 />
144 )}
145 {scrolledUp ? (
146 <Pressable
147 accessibilityLabel="Go to newest"
148 onPress={() => list.current?.scrollToOffset({ offset: 0, animated: true })}
149 style={[styles.jump, { backgroundColor: colors.surface, borderColor: colors.lineStrong }]}
150 >
151 <ArrowDown size={18} color={colors.fg} />
152 </Pressable>
153 ) : null}
154 </View>
155 <View style={styles.typing}>
156 <Text size="small" tone="faint" numberOfLines={1}>
157 {typingLine}
158 </Text>
159 {live.status !== "live" ? (
160 <Text size="small" tone="faint">
161 {live.status === "connecting" ? "Connecting…" : "Offline, trying again"}
162 </Text>
163 ) : null}
164 </View>
165 <Composer
166 draftKey={`${channelId}:${thread ?? ""}`}
167 placeholder={thread ? "Reply in thread" : `Message ${title}`}
168 onSend={(body) => {
169 send(body);
170 list.current?.scrollToOffset({ offset: 0, animated: true });
171 }}
172 onTyping={live.sayTyping}
173 onFocus={() => list.current?.scrollToOffset({ offset: 0, animated: true })}
174 />
175
176 <Sheet visible={!!picked} onClose={() => setPicked(null)}>
177 <View style={styles.quick}>
178 {QUICK.map((emoji) => {
179 const had = picked?.reactions?.find((one) => one.emoji === emoji)?.me ?? false;
180 return (
181 <Pressable
182 key={emoji}
183 onPress={() => {
184 if (picked) onReact(picked, emoji, !had);
185 setPicked(null);
186 }}
187 style={[styles.quickOne, { backgroundColor: had ? colors.accentLow : colors.raised }]}
188 >
189 <Text style={{ fontSize: 24, lineHeight: 30 }}>{emoji}</Text>
190 </Pressable>
191 );
192 })}
193 </View>
194 {!thread ? (
195 <SheetAction
196 icon={MessageSquareReply}
197 label="Reply in thread"
198 onPress={() => {
199 const message = picked;
200 setPicked(null);
201 if (message) router.push({ pathname: "/chat/thread", params: { channel: channelId, root: message.id } });
202 }}
203 />
204 ) : null}
205 <SheetAction
206 icon={Copy}
207 label="Copy text"
208 onPress={() => {
209 if (picked?.body) void Clipboard.setStringAsync(picked.body);
210 setPicked(null);
211 }}
212 />
213 {mine && picked?.kind === "text" ? (
214 <SheetAction
215 icon={Pencil}
216 label="Edit"
217 onPress={() => {
218 setEditing(picked);
219 setPicked(null);
220 }}
221 />
222 ) : null}
223 {mine ? (
224 <SheetAction
225 icon={Trash2}
226 label="Delete"
227 danger
228 onPress={() => {
229 const message = picked;
230 setPicked(null);
231 if (!message) return;
232 Alert.alert("Delete this message?", "It is gone for everyone in the conversation.", [
233 { text: "Cancel", style: "cancel" },
234 { text: "Delete", style: "destructive", onPress: () => actions.remove.mutate(message) },
235 ]);
236 }}
237 />
238 ) : null}
239 </Sheet>
240
241 <PromptSheet
242 visible={!!editing}
243 title="Edit message"
244 initial={editing?.body ?? ""}
245 multiline
246 confirm="Save"
247 onCancel={() => setEditing(null)}
248 onDone={(body) => {
249 if (editing && body !== editing.body) actions.edit.mutate({ message: editing, body });
250 setEditing(null);
251 }}
252 />
253 </KeyboardAvoidingView>
254 );
255}
256
257function dayLabel(date: Date): string {
258 const today = new Date();
259 const yesterday = new Date(today.getTime() - 86_400_000);
260 if (date.toDateString() === today.toDateString()) return "Today";
261 if (date.toDateString() === yesterday.toDateString()) return "Yesterday";
262 return date.toLocaleDateString(undefined, {
263 weekday: "long",
264 month: "long",
265 day: "numeric",
266 ...(date.getFullYear() !== today.getFullYear() ? { year: "numeric" } : {}),
267 });
268}
269
270function typingText(names: string[]): string {
271 if (names.length === 0) return "";
272 if (names.length === 1) return `${names[0]} is typing…`;
273 if (names.length === 2) return `${names[0]} and ${names[1]} are typing…`;
274 return "Several people are typing…";
275}
276
277const styles = StyleSheet.create({
278 day: { flexDirection: "row", alignItems: "center", gap: space.sm, paddingHorizontal: space.lg, paddingVertical: space.md },
279 dayLine: { flex: 1, height: StyleSheet.hairlineWidth },
280 jump: {
281 position: "absolute",
282 right: space.lg,
283 bottom: space.md,
284 width: 40,
285 height: 40,
286 borderRadius: 20,
287 borderWidth: 1,
288 alignItems: "center",
289 justifyContent: "center",
290 },
291 typing: { flexDirection: "row", justifyContent: "space-between", paddingHorizontal: space.lg, minHeight: 18 },
292 quick: { flexDirection: "row", justifyContent: "space-around", paddingHorizontal: space.md, paddingBottom: space.sm },
293 quickOne: { width: 48, height: 48, borderRadius: radius.full, alignItems: "center", justifyContent: "center" },
294});