import { MaterialCommunityIcons } from "@expo/vector-icons"; import { router, useFocusEffect } from "expo-router"; import { useCallback, useEffect, useMemo, useState } from "react"; import { ActivityIndicator, FlatList, Image, Keyboard, Pressable, Text, TextInput, TouchableOpacity, View, } from "react-native"; import { SafeAreaView, useSafeAreaInsets, } from "react-native-safe-area-context"; import { images } from "@/constants"; import { driverPhotoUri } from "@/lib/driver-photo"; import { fetchAPI } from "@/lib/fetch"; import { useT } from "@/lib/i18n"; import { ensureMicPermission } from "@/lib/use-call"; import { useChat } from "@/lib/use-chat"; import { useTheme } from "@/lib/theme"; import type { ChatActiveRide, Message } from "@/types/type"; const initials = (name: string): string => { const parts = name.trim().split(/\s+/).filter(Boolean); if (!parts.length) return "?"; return (parts[0][0] + (parts[1]?.[0] ?? "")).toUpperCase(); }; type ChatThreadProps = { /** * Extra clearance (px) the composer needs below the safe area — nonzero * when this screen sits under the rider's floating tab bar (position: * "absolute", ~78px tall + 20px margin), which doesn't reserve layout * space of its own and would otherwise sit on top of the composer. Pass 0 * for a standalone screen (no tab bar underneath, e.g. the driver's). */ tabBarClearance?: number; }; // Ride-scoped chat thread: header with the peer + call button, message list, // and composer. Shared by the rider's (tabs) Chat screen and the driver's // standalone chat screen — both resolve the same conversation via // GET /(api)/chat/active, which returns the correct peer for either role. export const ChatThread = ({ tabBarClearance = 0 }: ChatThreadProps) => { const t = useT(); const { isDark } = useTheme(); const insets = useSafeAreaInsets(); const [active, setActive] = useState(null); const [resolving, setResolving] = useState(true); // Resolve which conversation (if any) is open for the signed-in user. Re-run // whenever the screen is focused so a just-matched ride appears immediately. useFocusEffect( useCallback(() => { let cancelled = false; (async () => { setResolving(true); try { const res = await fetchAPI("/(api)/chat/active"); if (!cancelled) setActive((res.data ?? null) as ChatActiveRide); } catch (err) { console.log("[CHAT_ACTIVE]: ", err); if (!cancelled) setActive(null); } finally { if (!cancelled) setResolving(false); } })(); return () => { cancelled = true; }; }, []), ); const rideId = active?.ride_id ?? null; const role = active?.role ?? null; const { messages, loading, sending, sendMessage } = useChat(rideId, role); const [draft, setDraft] = useState(""); const peer = active?.peer ?? null; const peerName = peer?.name ?? ""; // Prime the mic permission as soon as a conversation (and its Call button) // is on screen, so the OS prompt lands here — not mid-handshake after the // user has already tapped Call and navigated to the call screen. const hasPeer = Boolean(peer); useEffect(() => { if (hasPeer) void ensureMicPermission(); }, [hasPeer]); const openCall = useCallback(() => { if (!active) return; router.push({ pathname: "/(root)/call", params: { rideId: String(active.ride_id), role: active.role, mode: "start", }, }); }, [active]); const submit = useCallback(() => { const text = draft.trim(); if (!text || sending) return; setDraft(""); void sendMessage(text); Keyboard.dismiss(); }, [draft, sending, sendMessage]); const renderBubble = useCallback( ({ item }: { item: Message }) => { const mine = item.sender_type === role; return ( {item.body} ); }, [role], ); const emptyConversation = useMemo( () => ( {t("chat.messageAlt")} {t("chat.noMessages")} {t("chat.startConversation")} ), [t], ); if (resolving) { return ( ); } return ( {/* Conversation header — only when a ride is matched */} {active && peer ? ( router.push({ pathname: "/(root)/book-ride", params: { id: String(active.ride_id) }, }) } className="flex-row items-center flex-1" > {peer.avatar ? ( ) : ( {initials(peerName)} )} {peerName} {peer.car_model ? ( {peer.car_model} ) : null} ) : null} {active && peer ? ( <> {loading && messages.length === 0 ? ( ) : ( String(m.id)} renderItem={renderBubble} contentContainerStyle={{ flexGrow: 1, paddingHorizontal: 16, paddingVertical: 12, }} onScrollBeginDrag={Keyboard.dismiss} keyboardShouldPersistTaps="never" ListEmptyComponent={emptyConversation} /> )} {/* Composer */} ) : ( {emptyConversation} )} ); };