From a4402d2c12fbdddc0de565bddf0890b69472046a Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 7 Oct 2026 01:11:02 +0800 Subject: [PATCH] feat(chat): real-time chat with the driver on an order - Order chat: driver header with presence, call button, intro notice, message bubbles with photos, times and read receipts, older messages on scroll, quick replies, photo sending, and optimistic sends that can be retried. - Live: new messages and receipts arrive on the chat's socket channel; a reconnecting banner shows while the socket is down. Read when opened. - States: no driver yet (409), load errors, closed chats read-only once the order is finished (and sends refused with 423 close the composer). - Tracking: a Message button with the unread count on the driver card, and "View messages" after the order is finished. Driver-message notifications open the chat. - src/commerce/order-chat.ts and http.ts (requests that keep refusal reasons), with tests; order.chat screen id. --- __tests__/commerce/order-chat.test.ts | 104 ++++++++ package.json | 2 +- src/commerce/http.ts | 44 ++++ src/commerce/order-chat.ts | 118 +++++++++ src/extensions/screens/default-screens.ts | 1 + src/extensions/screens/params.ts | 1 + src/extensions/screens/screen-ids.ts | 1 + src/hooks/use-chat-request.ts | 20 ++ src/navigation/stacks/OrderStack.tsx | 3 + src/screens/OrderScreen.tsx | 45 +++- src/screens/chat/OrderChatScreen.tsx | 276 ++++++++++++++++++++++ translations/en.json | 33 +++ translations/mn.json | 33 +++ translations/uk.json | 33 +++ 14 files changed, 710 insertions(+), 4 deletions(-) create mode 100644 __tests__/commerce/order-chat.test.ts create mode 100644 src/commerce/http.ts create mode 100644 src/commerce/order-chat.ts create mode 100644 src/hooks/use-chat-request.ts create mode 100644 src/screens/chat/OrderChatScreen.tsx diff --git a/__tests__/commerce/order-chat.test.ts b/__tests__/commerce/order-chat.test.ts new file mode 100644 index 00000000..3fd46d45 --- /dev/null +++ b/__tests__/commerce/order-chat.test.ts @@ -0,0 +1,104 @@ +import { ApiError, apiRequest } from '../../src/commerce/http'; +import { chatDriver, fetchChat, fetchOlderMessages, markChatRead, mergeMessages, parseChat, parseChatMessage, sendChatMessage } from '../../src/commerce/order-chat'; + +const message = (id: string, createdAt: string | null, extra: Record = {}) => ({ id, content: `m-${id}`, is_mine: false, sender: { name: 'Ravi K.' }, attachments: [], read: false, created_at: createdAt, ...extra }); + +const chatJson = { + id: 'chat_1', + channel: 'chat.chat_1', + order: 'order_1', + status: 'open', + participants: [ + { id: 'p1', role: 'customer', name: 'Ada B.', avatar_url: null, is_online: true }, + { id: 'p2', role: 'driver', name: 'Ravi K.', avatar_url: 'https://cdn.test/r.jpg', is_online: false }, + ], + messages: [message('m1', '2026-10-07T06:00:00Z'), message('', null)], + unread_count: 2, +}; + +describe('order chat', () => { + test('parses the chat and its messages', () => { + const chat = parseChat(chatJson); + expect(chat).toMatchObject({ id: 'chat_1', channel: 'chat.chat_1', orderId: 'order_1', status: 'open', unread: 2 }); + expect(chat.messages.map((entry) => entry.id)).toEqual(['m1']); + expect(chatDriver(chat)).toEqual({ id: 'p2', role: 'driver', name: 'Ravi K.', avatarUrl: 'https://cdn.test/r.jpg', online: false }); + expect(chatDriver(null)).toBeNull(); + expect(parseChat({ status: 'closed', participants: [{ role: 'x' }] })).toMatchObject({ status: 'closed', channel: null, unread: 0, participants: [{ id: '', role: 'driver', name: null, avatarUrl: null, online: false }] }); + expect(parseChatMessage({ id: 'm2', is_mine: true, read: true, attachments: [{ id: 'f1', url: 'https://cdn.test/p.jpg', type: 'image/jpeg' }, { id: 'f2' }, { url: 'https://cdn.test/q.jpg' }] })).toEqual({ + id: 'm2', + content: '', + mine: true, + senderName: null, + attachments: [ + { id: 'f1', url: 'https://cdn.test/p.jpg', type: 'image/jpeg' }, + { id: 'https://cdn.test/q.jpg', url: 'https://cdn.test/q.jpg', type: null }, + ], + read: true, + createdAt: null, + }); + }); + + test('merges messages, keeping local ones last until confirmed', () => { + const a = parseChatMessage(message('a', '2026-10-07T06:00:00Z')); + const b = parseChatMessage(message('b', '2026-10-07T06:01:00Z')); + const pending = { ...parseChatMessage(message('local-1', null)), state: 'sending' as const }; + const merged = mergeMessages([b, pending], [a, { ...b, read: true }]); + expect(merged.map((entry) => entry.id)).toEqual(['a', 'b', 'local-1']); + expect(merged[1].read).toBe(true); + expect(mergeMessages([], [parseChatMessage(message('x', null)), a]).map((entry) => entry.id)).toEqual(['a', 'x']); + }); + + test('calls the chat endpoints', async () => { + const request = jest.fn(); + request.mockResolvedValueOnce(chatJson); + expect((await fetchChat(request, 'order 1')).id).toBe('chat_1'); + request.mockResolvedValueOnce({ messages: [message('m0', '2026-10-07T05:00:00Z'), message('', null)] }); + expect((await fetchOlderMessages(request, 'order_1', 'm1')).map((entry) => entry.id)).toEqual(['m0']); + request.mockResolvedValueOnce(null); + expect(await fetchOlderMessages(request, 'order_1', 'm1', 10)).toEqual([]); + request.mockResolvedValueOnce({ message: message('m9', '2026-10-07T06:05:00Z', { is_mine: true }) }); + expect((await sendChatMessage(request, 'order_1', { content: ' Tower B ', photos: [1, 2, 3, 4, 5].map((n) => ({ data: `d${n}`, type: 'image/jpeg' })) })).mine).toBe(true); + request.mockResolvedValueOnce({ message: message('m10', null) }); + await sendChatMessage(request, 'order_1', {}); + request.mockResolvedValueOnce({ read: 2 }); + await markChatRead(request, 'order_1'); + expect(request.mock.calls).toEqual([ + ['orders/order%201/chat'], + ['orders/order_1/chat/messages', { query: { before: 'm1', limit: 30 } }], + ['orders/order_1/chat/messages', { query: { before: 'm1', limit: 10 } }], + ['orders/order_1/chat/messages', { method: 'POST', body: { content: 'Tower B', files: [1, 2, 3, 4].map((n) => ({ data: `d${n}`, type: 'image/jpeg' })) } }], + ['orders/order_1/chat/messages', { method: 'POST', body: {} }], + ['orders/order_1/chat/read', { method: 'POST' }], + ]); + }); +}); + +describe('apiRequest', () => { + const original = (globalThis as any).fetch; + afterEach(() => { + (globalThis as any).fetch = original; + }); + const target = { host: 'https://api.test', namespace: 'storefront/v1', headers: { Authorization: 'Bearer key' } }; + + test('sends JSON and returns the body', async () => { + (globalThis as any).fetch = jest.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ ok: 1 }) }); + expect(await apiRequest(target, 'orders/1/chat', { query: { before: 'm1', limit: 30, empty: '', none: null }, headers: { 'Customer-Token': 't' } })).toEqual({ ok: 1 }); + expect((globalThis as any).fetch).toHaveBeenCalledWith('https://api.test/storefront/v1/orders/1/chat?before=m1&limit=30', { method: 'GET', headers: { Authorization: 'Bearer key', 'Customer-Token': 't', Accept: 'application/json' } }); + await apiRequest(target, 'orders/1/chat/messages', { method: 'POST', body: { content: 'hi' } }); + expect((globalThis as any).fetch).toHaveBeenLastCalledWith('https://api.test/storefront/v1/orders/1/chat/messages', { method: 'POST', headers: { Authorization: 'Bearer key', Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ content: 'hi' }) }); + }); + + test('keeps the status and reason of refusals', async () => { + (globalThis as any).fetch = jest.fn().mockResolvedValue({ ok: false, status: 409, statusText: 'Conflict', json: () => Promise.resolve({ error: 'No driver yet', reason: 'driver_not_assigned' }) }); + await expect(apiRequest(target, 'x')).rejects.toMatchObject({ name: 'ApiError', status: 409, reason: 'driver_not_assigned', message: 'No driver yet' }); + (globalThis as any).fetch = jest.fn().mockResolvedValue({ ok: false, status: 500, statusText: 'Server Error', json: () => Promise.reject(new Error('html')) }); + await expect(apiRequest(target, 'x')).rejects.toMatchObject({ status: 500, reason: null, message: 'Server Error' }); + (globalThis as any).fetch = jest.fn().mockResolvedValue({ ok: false, status: 403, json: () => Promise.resolve({ errors: ['Not yours'] }) }); + await expect(apiRequest(target, 'x')).rejects.toMatchObject({ status: 403, message: 'Not yours' }); + (globalThis as any).fetch = jest.fn().mockRejectedValue(new Error('offline')); + await expect(apiRequest(target, 'x')).rejects.toMatchObject({ status: 0, reason: 'network', message: 'offline' }); + (globalThis as any).fetch = jest.fn().mockRejectedValue({}); + await expect(apiRequest(target, 'x')).rejects.toMatchObject({ reason: 'network', message: 'Network request failed' }); + await expect(apiRequest({}, 'x')).rejects.toBeInstanceOf(ApiError); + }); +}); diff --git a/package.json b/package.json index c58993c7..0a6e5939 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "test": "jest", "test:ci": "jest --runInBand", "lint": "eslint .", - "lint:strict": "eslint src/ui storefront.extensions.ts storefront.brand.ts src/env.ts src/branding src/hooks/use-app-theme.ts src/contexts/ThemeContext.tsx src/extensions src/navigation/NetworkNavigator.tsx src/navigation/AppNavigator.tsx src/network src/screens/network src/navigation/NetworkNavigator.tsx src/contexts/StorefrontRuntimeContext.tsx src/contexts/CartContext.tsx src/screens/BootScreen.tsx src/screens/LocationPermissionScreen.tsx src/commerce src/components/checkout src/components/booking src/hooks/use-cart-promotions.ts src/hooks/use-review-request.ts src/screens/reviews src/screens/offers src/components/offers src/screens/notifications src/hooks/use-customer-request.ts src/hooks/use-open-notification.ts src/hooks/use-unread-notifications.ts src/hooks/use-footer-offset.ts src/screens/CartScreen.tsx src/screens/OrderScreen.tsx src/screens/StripeCheckoutScreen.tsx src/screens/QPayCheckoutScreen.tsx web/react-native-mmkv-storage.js web/react-native-device-info.web.js __tests__", + "lint:strict": "eslint src/ui storefront.extensions.ts storefront.brand.ts src/env.ts src/branding src/hooks/use-app-theme.ts src/contexts/ThemeContext.tsx src/extensions src/navigation/NetworkNavigator.tsx src/navigation/AppNavigator.tsx src/network src/screens/network src/navigation/NetworkNavigator.tsx src/contexts/StorefrontRuntimeContext.tsx src/contexts/CartContext.tsx src/screens/BootScreen.tsx src/screens/LocationPermissionScreen.tsx src/commerce src/components/checkout src/components/booking src/hooks/use-cart-promotions.ts src/hooks/use-review-request.ts src/screens/reviews src/screens/offers src/components/offers src/screens/notifications src/screens/chat src/hooks/use-chat-request.ts src/hooks/use-customer-request.ts src/hooks/use-open-notification.ts src/hooks/use-unread-notifications.ts src/hooks/use-footer-offset.ts src/screens/CartScreen.tsx src/screens/OrderScreen.tsx src/screens/StripeCheckoutScreen.tsx src/screens/QPayCheckoutScreen.tsx web/react-native-mmkv-storage.js web/react-native-device-info.web.js __tests__", "typecheck:strict": "tsc --noEmit -p tsconfig.strict.json", "format": "prettier --write .", "tamagui:build": "./node_modules/.bin/tamagui-build", diff --git a/src/commerce/http.ts b/src/commerce/http.ts new file mode 100644 index 00000000..125573de --- /dev/null +++ b/src/commerce/http.ts @@ -0,0 +1,44 @@ +/** + * Storefront API requests that keep the error body. The SDK adapter rejects with only + * `errors[0]` or the status text, but some endpoints explain refusals as + * `{ error, reason }` with a meaningful status (409, 423, …) the app needs to show. + */ + +export type ApiTarget = { host?: string; namespace?: string; headers?: Record }; + +export class ApiError extends Error { + status: number; + reason: string | null; + + constructor(message: string, status: number, reason: string | null) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.reason = reason; + } +} + +function queryString(query: Record): string { + const params = Object.entries(query) + .filter(([, value]) => value !== undefined && value !== null && value !== '') + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + return params.length ? `?${params.join('&')}` : ''; +} + +export async function apiRequest(target: ApiTarget, path: string, { method = 'GET', query = {}, body, headers = {} }: { method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; query?: Record; body?: Record; headers?: Record } = {}): Promise { + if (!target.host || !target.namespace) throw new ApiError('Storefront is not ready', 0, null); + let response: Response; + try { + response = await fetch(`${target.host}/${target.namespace}/${path}${queryString(query)}`, { + method, + headers: { ...(target.headers ?? {}), ...headers, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + } catch (error: any) { + throw new ApiError(error?.message ?? 'Network request failed', 0, 'network'); + } + const json = await response.json().catch(() => null); + if (response.ok) return json; + const message = json?.error ?? json?.errors?.[0] ?? response.statusText ?? 'Request failed'; + throw new ApiError(String(message), response.status, typeof json?.reason === 'string' ? json.reason : null); +} diff --git a/src/commerce/order-chat.ts b/src/commerce/order-chat.ts new file mode 100644 index 00000000..c8fbb6e8 --- /dev/null +++ b/src/commerce/order-chat.ts @@ -0,0 +1,118 @@ +/** + * Chat between a customer and the driver delivering their order + * (storefront/v1/orders/{id}/chat), sent with the customer token: + * + * - GET orders/{id}/chat the chat with its latest messages + * 409 driver_not_assigned no driver yet + * - GET orders/{id}/chat/messages?before&limit older messages + * - POST orders/{id}/chat/messages { content?, files? } 423 chat_closed once the order ends + * - POST orders/{id}/chat/read mark the driver's messages read + * + * New messages arrive on the chat's socket channel (`chat.{id}`) as `chat_message.*` + * events; the app re-reads the latest page when one arrives. + */ + +export type ChatRequest = (path: string, options?: { method?: 'GET' | 'POST'; query?: Record; body?: Record }) => Promise; + +export type ChatAttachment = { id: string; url: string; type: string | null }; + +export type ChatMessage = { + id: string; + content: string; + mine: boolean; + senderName: string | null; + attachments: ChatAttachment[]; + read: boolean; + createdAt: string | null; + /** Sent from this device and not confirmed yet, or failed to send. */ + state?: 'sending' | 'failed'; +}; + +export type ChatParticipant = { id: string; role: 'customer' | 'driver'; name: string | null; avatarUrl: string | null; online: boolean }; + +export type OrderChat = { + id: string; + channel: string | null; + orderId: string | null; + status: 'open' | 'closed'; + participants: ChatParticipant[]; + messages: ChatMessage[]; + unread: number; +}; + +export const QUICK_REPLIES = ['comingDown', 'leaveAtDoor', 'callOnArrival', 'whereAreYou'] as const; +export const MAX_CHAT_PHOTOS = 4; + +export function parseChatMessage(json: any): ChatMessage { + return { + id: String(json?.id ?? ''), + content: String(json?.content ?? ''), + mine: json?.is_mine === true, + senderName: json?.sender?.name ?? null, + attachments: (Array.isArray(json?.attachments) ? json.attachments : []) + .filter((attachment: any) => typeof attachment?.url === 'string') + .map((attachment: any) => ({ id: String(attachment.id ?? attachment.url), url: attachment.url, type: attachment.type ?? null })), + read: json?.read === true, + createdAt: json?.created_at ?? null, + }; +} + +export function parseChat(json: any): OrderChat { + return { + id: String(json?.id ?? ''), + channel: typeof json?.channel === 'string' ? json.channel : null, + orderId: json?.order ?? null, + status: json?.status === 'closed' ? 'closed' : 'open', + participants: (Array.isArray(json?.participants) ? json.participants : []).map((participant: any) => ({ + id: String(participant?.id ?? ''), + role: participant?.role === 'customer' ? 'customer' : 'driver', + name: participant?.name ?? null, + avatarUrl: participant?.avatar_url ?? null, + online: participant?.is_online === true, + })), + messages: (Array.isArray(json?.messages) ? json.messages : []).map(parseChatMessage).filter((message: ChatMessage) => message.id), + unread: Math.max(0, Number(json?.unread_count) || 0), + }; +} + +const time = (message: ChatMessage) => (message.createdAt ? new Date(message.createdAt).getTime() : Infinity); + +/** + * Messages merged by id and ordered oldest first. Server copies replace local ones; + * messages still sending or failed stay at the end until they are confirmed. + */ +export function mergeMessages(current: ChatMessage[], incoming: ChatMessage[]): ChatMessage[] { + const byId = new Map(); + for (const message of current) byId.set(message.id, message); + for (const message of incoming) byId.set(message.id, message); + const all = [...byId.values()]; + const confirmed = all.filter((message) => !message.state).sort((a, b) => time(a) - time(b)); + const local = all.filter((message) => message.state); + return [...confirmed, ...local]; +} + +export async function fetchChat(request: ChatRequest, orderId: string): Promise { + return parseChat(await request(`orders/${encodeURIComponent(orderId)}/chat`)); +} + +export async function fetchOlderMessages(request: ChatRequest, orderId: string, beforeId: string, limit = 30): Promise { + const json = await request(`orders/${encodeURIComponent(orderId)}/chat/messages`, { query: { before: beforeId, limit } }); + return (Array.isArray(json?.messages) ? json.messages : []).map(parseChatMessage).filter((message: ChatMessage) => message.id); +} + +export async function sendChatMessage(request: ChatRequest, orderId: string, { content = '', photos = [] }: { content?: string; photos?: { data: string; type: string }[] }): Promise { + const body: Record = {}; + if (content.trim()) body.content = content.trim().slice(0, 2000); + if (photos.length) body.files = photos.slice(0, MAX_CHAT_PHOTOS); + const json = await request(`orders/${encodeURIComponent(orderId)}/chat/messages`, { method: 'POST', body }); + return parseChatMessage(json?.message); +} + +export async function markChatRead(request: ChatRequest, orderId: string): Promise { + await request(`orders/${encodeURIComponent(orderId)}/chat/read`, { method: 'POST' }); +} + +/** The driver taking part in the chat, if any. */ +export function chatDriver(chat: OrderChat | null): ChatParticipant | null { + return chat?.participants.find((participant) => participant.role === 'driver') ?? null; +} diff --git a/src/extensions/screens/default-screens.ts b/src/extensions/screens/default-screens.ts index 27d10017..0049933a 100644 --- a/src/extensions/screens/default-screens.ts +++ b/src/extensions/screens/default-screens.ts @@ -34,6 +34,7 @@ export const defaultScreens: ScreenRegistryDefinition = { 'network.product': { load: () => import('../../screens/network/NetworkProductScreen') }, 'reviews.list': { load: () => import('../../screens/reviews/StoreReviewsScreen') }, 'reviews.write': { load: () => import('../../screens/reviews/WriteReviewScreen') }, + 'order.chat': { load: () => import('../../screens/chat/OrderChatScreen') }, 'notifications.inbox': { load: () => import('../../screens/notifications/NotificationsScreen') }, 'notifications.settings': { load: () => import('../../screens/notifications/NotificationSettingsScreen') }, 'offers.list': { load: () => import('../../screens/offers/OffersScreen') }, diff --git a/src/extensions/screens/params.ts b/src/extensions/screens/params.ts index d590afe4..a8de7dc7 100644 --- a/src/extensions/screens/params.ts +++ b/src/extensions/screens/params.ts @@ -32,6 +32,7 @@ export type ScreenParamMap = { 'network.store': { storeId: string; store?: Serialized }; 'network.product': { storeId?: string; productId: string; product?: Serialized; store?: Serialized }; 'reviews.list': { storeId: string; storeName?: string; storeLogo?: string | null }; + 'order.chat': { orderId: string; orderReference?: string; storeName?: string; driverName?: string; driverPhone?: string }; 'notifications.inbox': undefined; 'notifications.settings': undefined; 'offers.list': { storeId?: string } | undefined; diff --git a/src/extensions/screens/screen-ids.ts b/src/extensions/screens/screen-ids.ts index eedcef4d..95461278 100644 --- a/src/extensions/screens/screen-ids.ts +++ b/src/extensions/screens/screen-ids.ts @@ -37,6 +37,7 @@ export const SCREEN_IDS = [ 'offers.detail', 'notifications.inbox', 'notifications.settings', + 'order.chat', ] as const; export type ScreenId = (typeof SCREEN_IDS)[number]; diff --git a/src/hooks/use-chat-request.ts b/src/hooks/use-chat-request.ts new file mode 100644 index 00000000..6651c0ae --- /dev/null +++ b/src/hooks/use-chat-request.ts @@ -0,0 +1,20 @@ +import { useCallback } from 'react'; +import useStorefront from './use-storefront'; +import { useAuth } from '../contexts/AuthContext'; +import { apiRequest } from '../commerce/http'; +import type { ChatRequest } from '../commerce/order-chat'; + +/** Order chat requests as the signed-in customer, keeping refusal reasons (409, 423 …). */ +export default function useChatRequest(): ChatRequest { + const { storefront } = useStorefront(); + const { customer } = useAuth(); + + return useCallback( + (path, options = {}) => { + const adapter = storefront?.getAdapter?.() ?? {}; + const token = customer?.token ?? customer?.getAttribute?.('token'); + return apiRequest({ host: adapter.host, namespace: adapter.namespace, headers: adapter.headers }, path, { ...options, headers: token ? { 'Customer-Token': token } : {} }); + }, + [customer, storefront] + ); +} diff --git a/src/navigation/stacks/OrderStack.tsx b/src/navigation/stacks/OrderStack.tsx index 56629c06..f9de2293 100644 --- a/src/navigation/stacks/OrderStack.tsx +++ b/src/navigation/stacks/OrderStack.tsx @@ -14,6 +14,8 @@ export const Order = { export const StoreReviews = { screen: screenSlot('reviews.list'), options: { headerShown: false } }; export const WriteReview = { screen: screenSlot('reviews.write'), options: { presentation: 'modal', headerShown: false } }; +export const OrderChat = { screen: screenSlot('order.chat'), options: { headerShown: false } }; + export const Receipt = { screen: screenSlot('order.receipt'), options: ({ navigation, route }) => { @@ -65,6 +67,7 @@ const OrderStack = { OrderHistory, StoreReviews, WriteReview, + OrderChat, }; export default OrderStack; diff --git a/src/screens/OrderScreen.tsx b/src/screens/OrderScreen.tsx index b287fdd9..d2b89c5f 100644 --- a/src/screens/OrderScreen.tsx +++ b/src/screens/OrderScreen.tsx @@ -1,11 +1,13 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Image, Linking, RefreshControl, ScrollView } from 'react-native'; +import { Image, Linking, Pressable, RefreshControl, ScrollView } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; -import { faCheck, faPhone, faReceipt, faStar, faXmark } from '@fortawesome/free-solid-svg-icons'; +import { faCheck, faComment, faPhone, faReceipt, faStar, faXmark } from '@fortawesome/free-solid-svg-icons'; import { parseScheduledAt } from '../commerce/booking'; import { fetchEligibility, type Eligibility } from '../commerce/reviews'; +import { fetchChat, type OrderChat } from '../commerce/order-chat'; +import useChatRequest from '../hooks/use-chat-request'; import useReviewRequest from '../hooks/use-review-request'; import { XStack, YStack, useTheme } from 'tamagui'; import { Order } from '@fleetbase/sdk'; @@ -56,6 +58,8 @@ const OrderScreen = ({ route }: any) => { const [pickupSheet, setPickupSheet] = useState(false); const [reviewState, setReviewState] = useState(null); const reviewRequest = useReviewRequest(); + const chatRequest = useChatRequest(); + const [chat, setChat] = useState(null); const storeId = useMemo(() => order.getAttribute('meta.storefront_id'), [order]); const [store, setStore] = useStorage(`${storeId}`, info); @@ -200,6 +204,28 @@ const OrderScreen = ({ route }: any) => { }; }, [customer, finished, order.id, reviewRequest, storeId]); + // The driver chat: its unread count while the order is active, its history after. + const hasDriver = !!order.getAttribute('driver_assigned') || !!order.getAttribute('driver_assigned_uuid'); + useEffect(() => { + if (!customer || !loaded || isPickup || (!hasDriver && !progress.finished)) return; + let active = true; + fetchChat(chatRequest, order.id) + .then((result) => active && setChat(result)) + .catch(() => active && setChat(null)); + return () => { + active = false; + }; + }, [chatRequest, customer, hasDriver, isPickup, loaded, order.id, progress.finished, status]); + + const openChat = () => + navigation.navigate('OrderChat', { + orderId: order.id, + orderReference: order.getAttribute('tracking_number.tracking_number') ?? order.id, + storeName: store?.name ?? undefined, + driverName: order.getAttribute('driver_assigned.name') ?? undefined, + driverPhone: order.getAttribute('driver_assigned.phone') ?? undefined, + }); + const close = () => { if (params.justPlaced || !navigation.canGoBack()) { navigation.navigate(mode === 'network' ? 'NetworkHomeTab' : 'StoreHomeTab'); @@ -377,7 +403,8 @@ const OrderScreen = ({ route }: any) => { )} - {!!driver?.phone && Linking.openURL(`tel:${driver.phone}`)} />} + {!!driver?.phone && Linking.openURL(`tel:${driver.phone}`)} />} + {!!customer && } )} @@ -406,6 +433,18 @@ const OrderScreen = ({ route }: any) => { ))} + {progress.finished && !!chat && chat.messages.length > 0 && ( + + + + {t('Chat.closedNote')}{' '} + + {t('Chat.viewMessages')} + + + + )} + diff --git a/src/screens/chat/OrderChatScreen.tsx b/src/screens/chat/OrderChatScreen.tsx new file mode 100644 index 00000000..046d20ac --- /dev/null +++ b/src/screens/chat/OrderChatScreen.tsx @@ -0,0 +1,276 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { FlatList, Image, KeyboardAvoidingView, Linking, Platform, Pressable, ScrollView, TextInput } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { launchImageLibrary } from 'react-native-image-picker'; +import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; +import { faArrowRotateRight, faCamera, faChevronLeft, faComments, faPaperPlane, faPhone } from '@fortawesome/free-solid-svg-icons'; +import { XStack, YStack, useTheme } from 'tamagui'; +import { useLanguage } from '../../contexts/LanguageContext'; +import { useAuth } from '../../contexts/AuthContext'; +import useChatRequest from '../../hooks/use-chat-request'; +import useSocketClusterClient from '../../hooks/use-socket-cluster-client'; +import { ApiError } from '../../commerce/http'; +import { MAX_CHAT_PHOTOS, QUICK_REPLIES, chatDriver, fetchChat, fetchOlderMessages, markChatRead, mergeMessages, sendChatMessage, type ChatMessage, type OrderChat } from '../../commerce/order-chat'; +import { shortName } from '../../commerce/order-progress'; +import { Button, EmptyState, ErrorState, IconButton, Skeleton, UIText, formatClock, initials, radius, space, usesTwelveHourClock } from '../../ui'; + +type Photo = { data: string; type: string; uri: string }; +type Row = ChatMessage & { photos?: Photo[] }; + +/** + * Real-time chat with the driver delivering an order. Messages load from the chat API, + * new ones arrive on the chat's socket channel, and sending is optimistic: a message shows + * straight away and can be retried if it fails. The chat becomes read-only once the order + * is finished. + */ +const OrderChatScreen = ({ route }: any) => { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const theme = useTheme(); + const { t, locale } = useLanguage(); + const { customer } = useAuth(); + const request = useChatRequest(); + const { listen, isConnected } = useSocketClusterClient(); + const hour12 = usesTwelveHourClock(locale); + const { orderId, orderReference, storeName, driverPhone } = route.params ?? {}; + const [chat, setChat] = useState(null); + const [messages, setMessages] = useState([]); + const [error, setError] = useState<'no_driver' | 'failed' | null>(null); + const [draft, setDraft] = useState(''); + const [loadingOlder, setLoadingOlder] = useState(false); + const [noMoreOlder, setNoMoreOlder] = useState(false); + const [closedNow, setClosedNow] = useState(false); + const listRef = useRef(null); + const counter = useRef(0); + + const refresh = useCallback(async () => { + if (!orderId) return; + try { + const next = await fetchChat(request, orderId); + setChat(next); + setMessages((current) => mergeMessages(current, next.messages)); + setError(null); + if (next.unread > 0) markChatRead(request, orderId).catch(() => {}); + } catch (failure) { + setError(failure instanceof ApiError && failure.reason === 'driver_not_assigned' ? 'no_driver' : 'failed'); + } + }, [orderId, request]); + + useEffect(() => { + refresh(); + }, [refresh]); + + // Live updates: any message or receipt event on the channel re-reads the latest page. + useEffect(() => { + if (!chat?.channel) return; + let listener: any = null; + let stopped = false; + listen(chat.channel, (event: any) => { + if (String(event?.event ?? '').startsWith('chat_')) refresh(); + }).then((subscription: any) => { + if (stopped) subscription?.stop?.(); + else listener = subscription; + }); + return () => { + stopped = true; + listener?.stop?.(); + }; + }, [chat?.channel, listen, refresh]); + + const loadOlder = async () => { + const oldest = messages.find((message) => !message.state); + if (!orderId || !oldest || loadingOlder || noMoreOlder) return; + setLoadingOlder(true); + try { + const older = await fetchOlderMessages(request, orderId, oldest.id); + setMessages((current) => mergeMessages(current, older)); + setNoMoreOlder(older.length === 0); + } catch { + setNoMoreOlder(true); + } finally { + setLoadingOlder(false); + } + }; + + const send = async (content: string, photos: Photo[] = [], retryId?: string) => { + if (!orderId || (!content.trim() && photos.length === 0)) return; + const localId = retryId ?? `local-${Date.now()}-${counter.current++}`; + const local: Row = { id: localId, content: content.trim(), mine: true, senderName: null, attachments: photos.map((photo, index) => ({ id: `${localId}-${index}`, url: photo.uri, type: photo.type })), read: false, createdAt: null, state: 'sending', photos }; + setMessages((current) => [...current.filter((message) => message.id !== localId), local]); + try { + const sent = await sendChatMessage(request, orderId, { content, photos: photos.map(({ data, type }) => ({ data, type })) }); + setMessages((current) => mergeMessages(current.filter((message) => message.id !== localId), [sent])); + } catch (failure) { + if (failure instanceof ApiError && failure.reason === 'chat_closed') { + setClosedNow(true); + setMessages((current) => current.filter((message) => message.id !== localId)); + return; + } + setMessages((current) => current.map((message) => (message.id === localId ? { ...message, state: 'failed' } : message))); + } + }; + + const submit = () => { + const text = draft; + setDraft(''); + send(text); + }; + + const pickPhotos = async () => { + const result = await launchImageLibrary({ mediaType: 'photo', includeBase64: true, selectionLimit: MAX_CHAT_PHOTOS, maxWidth: 1600, maxHeight: 1600, quality: 0.8 }); + const photos = (result.assets ?? []).filter((asset) => asset.base64 && asset.uri).map((asset) => ({ data: asset.base64!, type: asset.type ?? 'image/jpeg', uri: asset.uri! })); + if (photos.length) send('', photos); + }; + + const driver = chatDriver(chat); + const driverName = driver?.name ? shortName(driver.name) : (route.params?.driverName ?? t('Tracking.yourDriver')); + const closed = chat?.status === 'closed' || closedNow; + const data = useMemo(() => [...messages].reverse(), [messages]); + const time = (value: string | null) => { + if (!value) return ''; + const at = new Date(value); + return formatClock(at.getHours() * 60 + at.getMinutes(), hour12); + }; + + const header = ( + + navigation.goBack()} /> + + {driver?.avatarUrl ? : {initials(driverName)}} + {!!chat && } + + + + {driverName} + + + {[t('Chat.yourDriver'), orderReference ? `#${orderReference}` : null, closed ? t('Chat.closedShort') : driver?.online ? t('Chat.online') : null].filter(Boolean).join(' · ')} + + + {!!driverPhone && !closed && Linking.openURL(`tel:${driverPhone}`)} />} + + ); + + if (!customer) { + return ( + + {header} + + + ); + } + + return ( + + {header} + {!isConnected && !closed && !!chat && ( + + + + {t('Chat.reconnecting')} + + + )} + + {error === 'no_driver' ? ( + + ) : error === 'failed' && !chat ? ( + + ) : !chat ? ( + + + + + + ) : ( + + item.id} + onEndReached={loadOlder} + onEndReachedThreshold={0.3} + contentContainerStyle={{ padding: space.gutter, gap: 8 }} + accessibilityRole='list' + accessibilityLabel={t('Chat.messages')} + ListFooterComponent={ + + + {t('Chat.intro', { driver: driverName, store: storeName ?? t('StoreSwitch.thisStore') })} + + + } + renderItem={({ item }) => send(item.content, item.photos ?? [], item.id)} />} + /> + + {closed ? ( + + + {t('Chat.closedTitle')} + + + {t('Chat.closedBody', { store: storeName ?? t('StoreSwitch.thisStore') })} + + + ) : ( + + + {QUICK_REPLIES.map((key) => ( + send(t(`Chat.quick.${key}`))} accessibilityRole='button' style={{ height: 36, paddingHorizontal: 14, borderRadius: radius.pill, borderWidth: 1, borderColor: theme.borderColor.val, justifyContent: 'center' }}> + {t(`Chat.quick.${key}`)} + + ))} + + + + + + + + )} + + )} + + ); +}; + +function Bubble({ message, time, onRetry }: { message: Row; time: string; onRetry: () => void }) { + const theme = useTheme(); + const { t } = useLanguage(); + const meta = message.state === 'sending' ? t('Chat.sending') : message.state === 'failed' ? t('Chat.failed') : [time, message.mine && message.read ? t('Chat.read') : null].filter(Boolean).join(' · '); + return ( + + {message.attachments.map((attachment) => ( + + ))} + {!!message.content && ( + + {message.content} + + )} + {message.state === 'failed' ? ( + + ) : ( + + {meta} + + )} + + ); +} + +export default OrderChatScreen; diff --git a/translations/en.json b/translations/en.json index bb046d6f..d25dcf6e 100644 --- a/translations/en.json +++ b/translations/en.json @@ -1007,5 +1007,38 @@ "promotionsBody": "Deals and announcements from stores on {{name}}." }, "prefsNote": "Messages from your driver about an active order always come through. Receipts and account security emails aren’t affected by these settings." + }, + "Chat": { + "backToOrder": "Back to order tracking", + "yourDriver": "Your driver", + "online": "online", + "closedShort": "chat closed", + "messageDriver": "Message {{driver}}", + "messages": "Messages", + "intro": "{{driver}} is delivering your order from {{store}}. Messages are shared with the store if there’s a problem.", + "reconnecting": "Reconnecting… messages you send still go out, and new ones appear when you’re back online.", + "noDriverTitle": "No driver yet", + "noDriverBody": "You can message your driver once one is assigned to this order.", + "loadFailed": "Couldn’t load the chat", + "signInBody": "Sign in to message your driver.", + "quickReplies": "Quick replies", + "quick": { + "comingDown": "Coming down now", + "leaveAtDoor": "Please leave it at the door", + "callOnArrival": "Call me when you arrive", + "whereAreYou": "Where are you now?" + }, + "sendPhoto": "Send a photo", + "placeholder": "Message {{driver}}", + "send": "Send", + "sending": "Sending…", + "failed": "Not sent", + "retry": "Not sent · Tap to retry", + "read": "Read", + "photo": "Photo", + "closedTitle": "This chat closed when your order was finished", + "closedBody": "Need help with the order? Contact {{store}} from the order page.", + "closedNote": "Chat with your driver closed when the order was finished.", + "viewMessages": "View messages" } } diff --git a/translations/mn.json b/translations/mn.json index be0c38e9..bca4a428 100644 --- a/translations/mn.json +++ b/translations/mn.json @@ -1007,5 +1007,38 @@ "promotionsBody": "{{name}} дээрх дэлгүүрүүдийн урамшуулал, зарлал." }, "prefsNote": "Идэвхтэй захиалгын жолоочийн мессеж үргэлж ирнэ. Баримт болон бүртгэлийн аюулгүй байдлын имэйлд эдгээр тохиргоо нөлөөлөхгүй." + }, + "Chat": { + "backToOrder": "Захиалгын хяналт руу буцах", + "yourDriver": "Таны жолооч", + "online": "онлайн", + "closedShort": "чат хаагдсан", + "messageDriver": "{{driver}}-д бичих", + "messages": "Мессежүүд", + "intro": "{{driver}} таны захиалгыг {{store}}-с хүргэж байна. Асуудал гарвал мессежүүд дэлгүүрт харагдана.", + "reconnecting": "Дахин холбогдож байна… илгээсэн мессеж тань очих бөгөөд холбогдмогц шинэ мессежүүд харагдана.", + "noDriverTitle": "Жолооч хараахан томилогдоогүй", + "noDriverBody": "Жолооч томилогдсоны дараа түүнд бичих боломжтой.", + "loadFailed": "Чатыг ачаалж чадсангүй", + "signInBody": "Жолоочид бичихийн тулд нэвтэрнэ үү.", + "quickReplies": "Түргэн хариулт", + "quick": { + "comingDown": "Одоо буулаа", + "leaveAtDoor": "Хаалганы өмнө үлдээнэ үү", + "callOnArrival": "Ирэхдээ залгаарай", + "whereAreYou": "Та одоо хаана байна?" + }, + "sendPhoto": "Зураг илгээх", + "placeholder": "{{driver}}-д бичих", + "send": "Илгээх", + "sending": "Илгээж байна…", + "failed": "Илгээгдээгүй", + "retry": "Илгээгдээгүй · Дахин оролдох", + "read": "Уншсан", + "photo": "Зураг", + "closedTitle": "Захиалга дууссан тул чат хаагдсан", + "closedBody": "Захиалгын талаар тусламж хэрэгтэй юу? Захиалгын хуудаснаас {{store}}-тэй холбогдоно уу.", + "closedNote": "Захиалга дууссан тул жолоочтой хийсэн чат хаагдсан.", + "viewMessages": "Мессежүүдийг харах" } } diff --git a/translations/uk.json b/translations/uk.json index bc859cef..ee5971da 100644 --- a/translations/uk.json +++ b/translations/uk.json @@ -1021,5 +1021,38 @@ "promotionsBody": "Акції та оголошення магазинів на {{name}}." }, "prefsNote": "Повідомлення від водія щодо активного замовлення надходять завжди. Квитанції та листи щодо безпеки акаунта ці налаштування не змінюють." + }, + "Chat": { + "backToOrder": "Назад до відстеження", + "yourDriver": "Ваш водій", + "online": "в мережі", + "closedShort": "чат закрито", + "messageDriver": "Написати {{driver}}", + "messages": "Повідомлення", + "intro": "{{driver}} доставляє ваше замовлення з {{store}}. Якщо виникне проблема, повідомлення побачить магазин.", + "reconnecting": "Повторне підключення… надіслані повідомлення все одно підуть, а нові з’являться, коли з’єднання відновиться.", + "noDriverTitle": "Водія ще не призначено", + "noDriverBody": "Написати водієві можна, щойно його призначать на це замовлення.", + "loadFailed": "Не вдалося завантажити чат", + "signInBody": "Увійдіть, щоб написати водієві.", + "quickReplies": "Швидкі відповіді", + "quick": { + "comingDown": "Вже спускаюся", + "leaveAtDoor": "Залиште, будь ласка, біля дверей", + "callOnArrival": "Зателефонуйте, коли приїдете", + "whereAreYou": "Де ви зараз?" + }, + "sendPhoto": "Надіслати фото", + "placeholder": "Написати {{driver}}", + "send": "Надіслати", + "sending": "Надсилання…", + "failed": "Не надіслано", + "retry": "Не надіслано · Повторити", + "read": "Прочитано", + "photo": "Фото", + "closedTitle": "Чат закрито, бо замовлення завершено", + "closedBody": "Потрібна допомога із замовленням? Зв’яжіться з {{store}} на сторінці замовлення.", + "closedNote": "Чат із водієм закрито після завершення замовлення.", + "viewMessages": "Переглянути повідомлення" } }