| 1 | import type { CardAction, ChatMessage } from "@g1t/contracts"; |
| 2 | import { useQueryClient } from "@tanstack/react-query"; |
| 3 | import * as Clipboard from "expo-clipboard"; |
| 4 | import { router, useFocusEffect } from "expo-router"; |
| 5 | import { useHeaderHeight } from "expo-router/react-navigation"; |
| 6 | import { ArrowDown, Copy, MessageSquareReply, Pencil, Trash2 } from "lucide-react-native"; |
| 7 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 8 | import { ActivityIndicator, Alert, FlatList, KeyboardAvoidingView, Pressable, StyleSheet, View } from "react-native"; |
| 9 | |
| 10 | import { Composer } from "@/components/chat/composer"; |
| 11 | import { Message, runsOn } from "@/components/chat/message"; |
| 12 | import { PromptSheet, Sheet, SheetAction } from "@/components/chat/sheet"; |
| 13 | import { ErrorView, Loading, Text } from "@/components/ui"; |
| 14 | import { type Shown, markRead, useChannelLive, useChatActions, useMessages, useSend } from "@/lib/chat"; |
| 15 | import { useSignedIn } from "@/lib/session"; |
| 16 | import { viewing } from "@/lib/viewing"; |
| 17 | import { radius, space, useColors } from "@/theme"; |
| 18 | |
| 19 | const QUICK = ["👍", "❤️", "😂", "🎉", "👀", "✅"]; |
| 20 | |
| 21 | type 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 | */ |
| 28 | export 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 | |
| 274 | function 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 | |
| 282 | function 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 | |
| 289 | const 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 | }); |