Waseel: driver capture, chat/calls, dispatch, and session fixes
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1d84003e0a
commit
8807ff41c5
@@ -0,0 +1,278 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user