Skip to content
286 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
125 message={item.message}
126 head={item.head}
127 inThread={!!thread}
128 onLongPress={setPicked}
129 onReact={onReact}
130 onRetry={onRetry}
131 />
132 )
133 }
134 onEndReached={() => messages.hasNextPage && !messages.isFetchingNextPage && messages.fetchNextPage()}
135 onEndReachedThreshold={0.6}
136 onScroll={(event) => setScrolledUp(event.nativeEvent.contentOffset.y > 600)}
137 scrollEventThrottle={100}
138 ListHeaderComponent={<View style={{ height: space.sm }} />}
139 ListFooterComponent={
140 messages.isFetchingNextPage ? (
141 <ActivityIndicator style={{ margin: space.lg }} color={colors.accent} />
142 ) : !messages.hasNextPage ? (
143 <Text size="small" tone="faint" style={{ textAlign: "center", padding: space.xl }}>
144 {thread ? "The start of this thread." : `The start of ${title}.`}
145 </Text>
146 ) : null
147 }
148 keyboardDismissMode="interactive"
149 keyboardShouldPersistTaps="handled"
150 />
151 )}
152 {scrolledUp ? (
153 <Pressable
154 accessibilityLabel="Go to newest"
155 onPress={() => list.current?.scrollToOffset({ offset: 0, animated: true })}
156 style={[styles.jump, { backgroundColor: colors.surface, borderColor: colors.lineStrong }]}
157 >
158 <ArrowDown size={18} color={colors.fg} />
159 </Pressable>
160 ) : null}
161 </View>
162 <View style={styles.typing}>
163 <Text size="small" tone="faint" numberOfLines={1}>
164 {typingLine}
165 </Text>
166 {live.status !== "live" ? (
167 <Text size="small" tone="faint">
168 {live.status === "connecting" ? "Connecting…" : "Offline, trying again"}
169 </Text>
170 ) : null}
171 </View>
172 <Composer
173 draftKey={`${channelId}:${thread ?? ""}`}
174 placeholder={thread ? "Reply in thread" : `Message ${title}`}
175 onSend={(body) => {
176 send(body);
177 list.current?.scrollToOffset({ offset: 0, animated: true });
178 }}
179 onTyping={live.sayTyping}
180 onFocus={() => list.current?.scrollToOffset({ offset: 0, animated: true })}
181 />
182
183 <Sheet visible={!!picked} onClose={() => setPicked(null)}>
184 <View style={styles.quick}>
185 {QUICK.map((emoji) => {
186 const had = picked?.reactions?.find((one) => one.emoji === emoji)?.me ?? false;
187 return (
188 <Pressable
189 key={emoji}
190 onPress={() => {
191 if (picked) onReact(picked, emoji, !had);
192 setPicked(null);
193 }}
194 style={[styles.quickOne, { backgroundColor: had ? colors.accentLow : colors.raised }]}
195 >
196 <Text style={{ fontSize: 24, lineHeight: 30 }}>{emoji}</Text>
197 </Pressable>
198 );
199 })}
200 </View>
201 {!thread ? (
202 <SheetAction
203 icon={MessageSquareReply}
204 label="Reply in thread"
205 onPress={() => {
206 const message = picked;
207 setPicked(null);
208 if (message) router.push({ pathname: "/chat/thread", params: { channel: channelId, root: message.id } });
209 }}
210 />
211 ) : null}
212 <SheetAction
213 icon={Copy}
214 label="Copy text"
215 onPress={() => {
216 if (picked?.body) void Clipboard.setStringAsync(picked.body);
217 setPicked(null);
218 }}
219 />
220 {mine && picked?.kind === "text" ? (
221 <SheetAction
222 icon={Pencil}
223 label="Edit"
224 onPress={() => {
225 setEditing(picked);
226 setPicked(null);
227 }}
228 />
229 ) : null}
230 {mine ? (
231 <SheetAction
232 icon={Trash2}
233 label="Delete"
234 danger
235 onPress={() => {
236 const message = picked;
237 setPicked(null);
238 if (!message) return;
239 Alert.alert("Delete this message?", "It is gone for everyone in the conversation.", [
240 { text: "Cancel", style: "cancel" },
241 { text: "Delete", style: "destructive", onPress: () => actions.remove.mutate(message) },
242 ]);
243 }}
244 />
245 ) : null}
246 </Sheet>
247
248 <PromptSheet
249 visible={!!editing}
250 title="Edit message"
251 initial={editing?.body ?? ""}
252 multiline
253 confirm="Save"
254 onCancel={() => setEditing(null)}
255 onDone={(body) => {
256 if (editing && body !== editing.body) actions.edit.mutate({ message: editing, body });
257 setEditing(null);
258 }}
259 />
260 </KeyboardAvoidingView>
261 );
262}
263
264function dayLabel(date: Date): string {
265 const today = new Date();
266 const yesterday = new Date(today.getTime() - 86_400_000);
267 if (date.toDateString() === today.toDateString()) return "Today";
268 if (date.toDateString() === yesterday.toDateString()) return "Yesterday";
269 return date.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric", ...(date.getFullYear() !== today.getFullYear() ? { year: "numeric" } : {}) });
270}
271
272function typingText(names: string[]): string {
273 if (names.length === 0) return "";
274 if (names.length === 1) return `${names[0]} is typing…`;
275 if (names.length === 2) return `${names[0]} and ${names[1]} are typing…`;
276 return "Several people are typing…";
277}
278
279const styles = StyleSheet.create({
280 day: { flexDirection: "row", alignItems: "center", gap: space.sm, paddingHorizontal: space.lg, paddingVertical: space.md },
281 dayLine: { flex: 1, height: StyleSheet.hairlineWidth },
282 jump: { position: "absolute", right: space.lg, bottom: space.md, width: 40, height: 40, borderRadius: 20, borderWidth: 1, alignItems: "center", justifyContent: "center" },
283 typing: { flexDirection: "row", justifyContent: "space-between", paddingHorizontal: space.lg, minHeight: 18 },
284 quick: { flexDirection: "row", justifyContent: "space-around", paddingHorizontal: space.md, paddingBottom: space.sm },
285 quickOne: { width: 48, height: 48, borderRadius: radius.full, alignItems: "center", justifyContent: "center" },
286});