Driver onboarding now photographs the licence, ID card and vehicle
registration and reads the credential fields off them, plus a camera-only
profile selfie riders check the arriving driver against. Adds in-app chat
and WebRTC calls, push-backed ride offers, ratings, cancellation and
payment sheets, settlement, and the owner dashboard endpoints behind them.
Camera permission on Android:
- Declare CAMERA and READ_MEDIA_IMAGES in the manifest. expo-image-picker's
own plugin never declares CAMERA, and Android denies a request for an
undeclared permission instantly and silently — no dialog is ever shown,
which is indistinguishable from the app not asking at all.
- Handle canAskAgain: once Android stops showing the dialog, repeating why
we need it is a dead end, so offer Open Settings instead (lib/capture-
permission.ts), matching what the location flow already did.
Session: a 401 on a request that carried a token now ends the session
instead of being reinterpreted per-screen — driver-home had been reading it
as "this user has no driver profile" and showing an onboarding form to an
already-onboarded driver. Requests without a token are exempt so a failed
sign-in doesn't sign you out, and the notification is latched per token so
concurrent polls tear the session down once. (root) gains the auth guard
that turns that into the sign-in screen; app/index.tsx only guarded the way
in, leaving a session that ended mid-screen with nowhere to go.
Also ignore .uploads/ — it holds driver licence, ID and vehicle scans plus
profile photos, which are personal data and must not be committed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
279 lines
9.1 KiB
TypeScript
279 lines
9.1 KiB
TypeScript
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<ChatActiveRide | null>(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 (
|
|
<View
|
|
className={`flex-row ${mine ? "justify-end" : "justify-start"} my-1`}
|
|
>
|
|
<View
|
|
className={`max-w-[78%] rounded-2xl px-4 py-2.5 ${
|
|
mine ? "bg-general-400" : "bg-neutral-100 dark:bg-neutral-800"
|
|
}`}
|
|
>
|
|
<Text
|
|
className={`text-[15px] ${
|
|
mine ? "text-white" : "text-black dark:text-white"
|
|
}`}
|
|
>
|
|
{item.body}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
);
|
|
},
|
|
[role],
|
|
);
|
|
|
|
const emptyConversation = useMemo(
|
|
() => (
|
|
<View className="flex-1 h-fit flex justify-center items-center">
|
|
<Image
|
|
source={images.message}
|
|
alt={t("chat.messageAlt")}
|
|
className="w-full h-40"
|
|
resizeMode="contain"
|
|
/>
|
|
<Text className="text-3xl font-JakartaBold mt-3 text-black dark:text-white">
|
|
{t("chat.noMessages")}
|
|
</Text>
|
|
<Text className="text-base mt-2 text-center px-7 text-general-200 dark:text-neutral-400">
|
|
{t("chat.startConversation")}
|
|
</Text>
|
|
</View>
|
|
),
|
|
[t],
|
|
);
|
|
|
|
if (resolving) {
|
|
return (
|
|
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950 items-center justify-center">
|
|
<ActivityIndicator size="large" color={isDark ? "#fff" : "#0286ff"} />
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<SafeAreaView
|
|
className="flex-1 bg-white dark:bg-neutral-950"
|
|
edges={["top"]}
|
|
>
|
|
{/* Conversation header — only when a ride is matched */}
|
|
{active && peer ? (
|
|
<View className="flex-row items-center px-4 py-3 border-b border-neutral-100 dark:border-neutral-800">
|
|
<Pressable
|
|
onPress={() =>
|
|
router.push({
|
|
pathname: "/(root)/book-ride",
|
|
params: { id: String(active.ride_id) },
|
|
})
|
|
}
|
|
className="flex-row items-center flex-1"
|
|
>
|
|
{peer.avatar ? (
|
|
<Image
|
|
source={{ uri: driverPhotoUri(peer.avatar) }}
|
|
className="w-10 h-10 rounded-full bg-neutral-200 dark:bg-neutral-700"
|
|
resizeMode="cover"
|
|
/>
|
|
) : (
|
|
<View className="w-10 h-10 rounded-full bg-general-400 items-center justify-center">
|
|
<Text className="text-white font-JakartaBold">
|
|
{initials(peerName)}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
<View className="ml-3">
|
|
<Text className="text-base font-JakartaBold text-black dark:text-white">
|
|
{peerName}
|
|
</Text>
|
|
{peer.car_model ? (
|
|
<Text className="text-xs text-general-200 dark:text-neutral-400">
|
|
{peer.car_model}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
</Pressable>
|
|
|
|
<TouchableOpacity
|
|
onPress={openCall}
|
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
accessibilityLabel={t("chat.call")}
|
|
className="w-10 h-10 rounded-full bg-general-300 dark:bg-neutral-800 items-center justify-center"
|
|
>
|
|
<MaterialCommunityIcons name="phone" size={20} color="white" />
|
|
</TouchableOpacity>
|
|
</View>
|
|
) : null}
|
|
|
|
{active && peer ? (
|
|
<>
|
|
{loading && messages.length === 0 ? (
|
|
<View className="flex-1 items-center justify-center">
|
|
<ActivityIndicator
|
|
size="small"
|
|
color={isDark ? "#fff" : "#0286ff"}
|
|
/>
|
|
</View>
|
|
) : (
|
|
<FlatList
|
|
data={messages}
|
|
keyExtractor={(m) => String(m.id)}
|
|
renderItem={renderBubble}
|
|
contentContainerStyle={{
|
|
flexGrow: 1,
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 12,
|
|
}}
|
|
onScrollBeginDrag={Keyboard.dismiss}
|
|
keyboardShouldPersistTaps="never"
|
|
ListEmptyComponent={emptyConversation}
|
|
/>
|
|
)}
|
|
|
|
{/* Composer */}
|
|
<View
|
|
className="flex-row items-center px-3 py-2 border-t border-neutral-100 dark:border-neutral-800"
|
|
style={{ paddingBottom: insets.bottom + 8 + tabBarClearance }}
|
|
>
|
|
<TextInput
|
|
value={draft}
|
|
onChangeText={setDraft}
|
|
placeholder={t("chat.inputPlaceholder")}
|
|
placeholderTextColor={isDark ? "#737373" : "#9ca3af"}
|
|
className="flex-1 min-h-[44px] max-h-28 rounded-full bg-neutral-100 dark:bg-neutral-800 px-4 py-2.5 text-[15px] text-black dark:text-white"
|
|
multiline
|
|
/>
|
|
<TouchableOpacity
|
|
onPress={submit}
|
|
disabled={sending || !draft.trim()}
|
|
accessibilityLabel={t("chat.send")}
|
|
className="w-11 h-11 ml-2 rounded-full bg-general-400 items-center justify-center disabled:opacity-40"
|
|
>
|
|
<MaterialCommunityIcons name="send" size={20} color="white" />
|
|
</TouchableOpacity>
|
|
</View>
|
|
</>
|
|
) : (
|
|
<View className="flex-1 px-5">{emptyConversation}</View>
|
|
)}
|
|
</SafeAreaView>
|
|
);
|
|
};
|