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