Skip to content
296 linesCodeBlameRaw
1import type { CardAction, 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 onCardAction = useCallback(
93 async (message: ChatMessage, action: CardAction, input?: string | null) => {
94 const answer = await actions.cardAction.mutateAsync({ message, action: action.id, input });
95 if (!answer.ok) throw new Error(answer.message ?? "That didn't work.");
96 return answer.message;
97 },
98 [actions.cardAction],
99 );
100
101 const onReact = useCallback((message: ChatMessage, emoji: string, add: boolean) => actions.react.mutate({ message, emoji, add }), [actions.react]);
102 const onRetry = useCallback((message: Shown) => send(message.body, message), [send]);
103
104 const mine = picked?.author.kind === "user" && picked.author.id === me.id;
105 const typingLine = typingText(live.typing.map((member) => member.display_name));
106
107 return (
108 // The keyboard pushes the composer up and the list shrinks from the top, on
109 // Android too: apps draw edge to edge there, so the window no longer resizes
110 // for it. The list is drawn from the bottom, so the newest stays in view.
111 <KeyboardAvoidingView style={{ flex: 1 }} behavior="padding" keyboardVerticalOffset={headerHeight}>
112 <View style={{ flex: 1 }}>
113 {messages.isPending ? (
114 <Loading />
115 ) : messages.error ? (
116 <ErrorView error={messages.error} retry={() => messages.refetch()} />
117 ) : (
118 <FlatList
119 ref={list}
120 inverted
121 data={rows}
122 keyExtractor={(row) => (row.kind === "day" ? row.key : row.message.id)}
123 renderItem={({ item }) =>
124 item.kind === "day" ? (
125 <View style={styles.day}>
126 <View style={[styles.dayLine, { backgroundColor: colors.line }]} />
127 <Text size="small" tone="faint" style={{ fontWeight: "600" }}>
128 {item.label}
129 </Text>
130 <View style={[styles.dayLine, { backgroundColor: colors.line }]} />
131 </View>
132 ) : (
133 <Message
134 message={item.message}
135 head={item.head}
136 inThread={!!thread}
137 onLongPress={setPicked}
138 onReact={onReact}
139 onRetry={onRetry}
140 onCardAction={onCardAction}
141 />
142 )
143 }
144 onEndReached={() => messages.hasNextPage && !messages.isFetchingNextPage && messages.fetchNextPage()}
145 onEndReachedThreshold={0.6}
146 onScroll={(event) => setScrolledUp(event.nativeEvent.contentOffset.y > 600)}
147 scrollEventThrottle={100}
148 ListHeaderComponent={<View style={{ height: space.sm }} />}
149 ListFooterComponent={
150 messages.isFetchingNextPage ? (
151 <ActivityIndicator style={{ margin: space.lg }} color={colors.accent} />
152 ) : !messages.hasNextPage ? (
153 <Text size="small" tone="faint" style={{ textAlign: "center", padding: space.xl }}>
154 {thread ? "The start of this thread." : `The start of ${title}.`}
155 </Text>
156 ) : null
157 }
158 keyboardDismissMode="interactive"
159 keyboardShouldPersistTaps="handled"
160 />
161 )}
162 {scrolledUp ? (
163 <Pressable
164 accessibilityLabel="Go to newest"
165 onPress={() => list.current?.scrollToOffset({ offset: 0, animated: true })}
166 style={[styles.jump, { backgroundColor: colors.surface, borderColor: colors.lineStrong }]}
167 >
168 <ArrowDown size={18} color={colors.fg} />
169 </Pressable>
170 ) : null}
171 </View>
172 <View style={styles.typing}>
173 <Text size="small" tone="faint" numberOfLines={1}>
174 {typingLine}
175 </Text>
176 {live.status !== "live" ? (
177 <Text size="small" tone="faint">
178 {live.status === "connecting" ? "Connecting…" : "Offline, trying again"}
179 </Text>
180 ) : null}
181 </View>
182 <Composer
183 draftKey={`${channelId}:${thread ?? ""}`}
184 placeholder={thread ? "Reply in thread" : `Message ${title}`}
185 onSend={(body) => {
186 send(body);
187 list.current?.scrollToOffset({ offset: 0, animated: true });
188 }}
189 onTyping={live.sayTyping}
190 onFocus={() => list.current?.scrollToOffset({ offset: 0, animated: true })}
191 />
192
193 <Sheet visible={!!picked} onClose={() => setPicked(null)}>
194 <View style={styles.quick}>
195 {QUICK.map((emoji) => {
196 const had = picked?.reactions?.find((one) => one.emoji === emoji)?.me ?? false;
197 return (
198 <Pressable
199 key={emoji}
200 onPress={() => {
201 if (picked) onReact(picked, emoji, !had);
202 setPicked(null);
203 }}
204 style={[styles.quickOne, { backgroundColor: had ? colors.accentLow : colors.raised }]}
205 >
206 <Text style={{ fontSize: 24, lineHeight: 30 }}>{emoji}</Text>
207 </Pressable>
208 );
209 })}
210 </View>
211 {!thread ? (
212 <SheetAction
213 icon={MessageSquareReply}
214 label="Reply in thread"
215 onPress={() => {
216 const message = picked;
217 setPicked(null);
218 if (message) router.push({ pathname: "/chat/thread", params: { channel: channelId, root: message.id } });
219 }}
220 />
221 ) : null}
222 <SheetAction
223 icon={Copy}
224 label="Copy text"
225 onPress={() => {
226 if (picked?.body) void Clipboard.setStringAsync(picked.body);
227 setPicked(null);
228 }}
229 />
230 {mine && picked?.kind === "text" ? (
231 <SheetAction
232 icon={Pencil}
233 label="Edit"
234 onPress={() => {
235 setEditing(picked);
236 setPicked(null);
237 }}
238 />
239 ) : null}
240 {mine ? (
241 <SheetAction
242 icon={Trash2}
243 label="Delete"
244 danger
245 onPress={() => {
246 const message = picked;
247 setPicked(null);
248 if (!message) return;
249 Alert.alert("Delete this message?", "It is gone for everyone in the conversation.", [
250 { text: "Cancel", style: "cancel" },
251 { text: "Delete", style: "destructive", onPress: () => actions.remove.mutate(message) },
252 ]);
253 }}
254 />
255 ) : null}
256 </Sheet>
257
258 <PromptSheet
259 visible={!!editing}
260 title="Edit message"
261 initial={editing?.body ?? ""}
262 multiline
263 confirm="Save"
264 onCancel={() => setEditing(null)}
265 onDone={(body) => {
266 if (editing && body !== editing.body) actions.edit.mutate({ message: editing, body });
267 setEditing(null);
268 }}
269 />
270 </KeyboardAvoidingView>
271 );
272}
273
274function dayLabel(date: Date): string {
275 const today = new Date();
276 const yesterday = new Date(today.getTime() - 86_400_000);
277 if (date.toDateString() === today.toDateString()) return "Today";
278 if (date.toDateString() === yesterday.toDateString()) return "Yesterday";
279 return date.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric", ...(date.getFullYear() !== today.getFullYear() ? { year: "numeric" } : {}) });
280}
281
282function typingText(names: string[]): string {
283 if (names.length === 0) return "";
284 if (names.length === 1) return `${names[0]} is typing…`;
285 if (names.length === 2) return `${names[0]} and ${names[1]} are typing…`;
286 return "Several people are typing…";
287}
288
289const styles = StyleSheet.create({
290 day: { flexDirection: "row", alignItems: "center", gap: space.sm, paddingHorizontal: space.lg, paddingVertical: space.md },
291 dayLine: { flex: 1, height: StyleSheet.hairlineWidth },
292 jump: { position: "absolute", right: space.lg, bottom: space.md, width: 40, height: 40, borderRadius: 20, borderWidth: 1, alignItems: "center", justifyContent: "center" },
293 typing: { flexDirection: "row", justifyContent: "space-between", paddingHorizontal: space.lg, minHeight: 18 },
294 quick: { flexDirection: "row", justifyContent: "space-around", paddingHorizontal: space.md, paddingBottom: space.sm },
295 quickOne: { width: 48, height: 48, borderRadius: radius.full, alignItems: "center", justifyContent: "center" },
296});