turbodesk-livechat-react-native 0.1.0-alpha.3 → 0.1.0-alpha.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/api/conversation-api.d.ts +9 -0
  3. package/dist/api/conversation-api.d.ts.map +1 -1
  4. package/dist/api/conversation-api.js +15 -1
  5. package/dist/api/conversation-api.js.map +1 -1
  6. package/dist/hooks/use-live-chat.d.ts +1 -0
  7. package/dist/hooks/use-live-chat.d.ts.map +1 -1
  8. package/dist/hooks/use-live-chat.js +2 -0
  9. package/dist/hooks/use-live-chat.js.map +1 -1
  10. package/dist/hooks/use-send-message.d.ts +1 -0
  11. package/dist/hooks/use-send-message.d.ts.map +1 -1
  12. package/dist/hooks/use-send-message.js +20 -14
  13. package/dist/hooks/use-send-message.js.map +1 -1
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +4 -2
  17. package/dist/index.js.map +1 -1
  18. package/dist/navigation/LiveChatPanel.d.ts.map +1 -1
  19. package/dist/navigation/LiveChatPanel.js +20 -1
  20. package/dist/navigation/LiveChatPanel.js.map +1 -1
  21. package/dist/provider/LiveChatContext.d.ts.map +1 -1
  22. package/dist/provider/LiveChatContext.js +2 -0
  23. package/dist/provider/LiveChatContext.js.map +1 -1
  24. package/dist/provider/LiveChatProvider.d.ts +1 -1
  25. package/dist/provider/LiveChatProvider.d.ts.map +1 -1
  26. package/dist/provider/LiveChatProvider.js +17 -2
  27. package/dist/provider/LiveChatProvider.js.map +1 -1
  28. package/dist/provider/types.d.ts +4 -0
  29. package/dist/provider/types.d.ts.map +1 -1
  30. package/dist/ui/components/ConversationHeader.d.ts.map +1 -1
  31. package/dist/ui/components/ConversationHeader.js +7 -4
  32. package/dist/ui/components/ConversationHeader.js.map +1 -1
  33. package/dist/ui/components/ConversationListScreen.d.ts.map +1 -1
  34. package/dist/ui/components/ConversationListScreen.js +11 -2
  35. package/dist/ui/components/ConversationListScreen.js.map +1 -1
  36. package/dist/ui/components/ConversationScreen.d.ts.map +1 -1
  37. package/dist/ui/components/ConversationScreen.js +13 -3
  38. package/dist/ui/components/ConversationScreen.js.map +1 -1
  39. package/dist/ui/components/HomeScreen.d.ts.map +1 -1
  40. package/dist/ui/components/HomeScreen.js +14 -4
  41. package/dist/ui/components/HomeScreen.js.map +1 -1
  42. package/dist/ui/components/MessageComposer.d.ts +1 -0
  43. package/dist/ui/components/MessageComposer.d.ts.map +1 -1
  44. package/dist/ui/components/MessageComposer.js +89 -30
  45. package/dist/ui/components/MessageComposer.js.map +1 -1
  46. package/dist/ui/safe-area.d.ts +9 -0
  47. package/dist/ui/safe-area.d.ts.map +1 -0
  48. package/dist/ui/safe-area.js +28 -0
  49. package/dist/ui/safe-area.js.map +1 -0
  50. package/package.json +11 -3
  51. package/src/api/conversation-api.ts +33 -8
  52. package/src/hooks/use-live-chat.ts +3 -0
  53. package/src/hooks/use-send-message.ts +169 -159
  54. package/src/index.ts +3 -0
  55. package/src/navigation/LiveChatPanel.tsx +26 -3
  56. package/src/provider/LiveChatContext.ts +2 -0
  57. package/src/provider/LiveChatProvider.tsx +396 -380
  58. package/src/provider/types.ts +63 -57
  59. package/src/ui/components/ConversationHeader.tsx +7 -6
  60. package/src/ui/components/ConversationListScreen.tsx +18 -5
  61. package/src/ui/components/ConversationScreen.tsx +369 -362
  62. package/src/ui/components/HomeScreen.tsx +21 -6
  63. package/src/ui/components/MessageComposer.tsx +116 -36
  64. package/src/ui/safe-area.ts +34 -0
@@ -1,362 +1,369 @@
1
- import React, { useCallback, useEffect, useRef, useState } from "react";
2
- import {
3
- ActivityIndicator,
4
- FlatList,
5
- KeyboardAvoidingView,
6
- Platform,
7
- StyleSheet,
8
- Text,
9
- TouchableOpacity,
10
- View,
11
- } from "react-native";
12
- import { useLiveChatContext } from "../../provider/LiveChatContext";
13
- import { conversationApi } from "../../api/conversation-api";
14
- import { useMessages } from "../../hooks/use-messages";
15
- import { useSendMessage } from "../../hooks/use-send-message";
16
- import { ConversationHeader } from "./ConversationHeader";
17
- import { MessageBubble } from "./MessageBubble";
18
- import { LogMessage } from "./LogMessage";
19
- import { MessageComposer } from "./MessageComposer";
20
- import type { UploadedAttachment } from "./MessageComposer";
21
- import type { InteractiveReplyPayload } from "./LivechatMessageRenderer";
22
- import {
23
- POWERED_BY_TEXT,
24
- getWidgetGreeting,
25
- } from "../../ui/theme";
26
- import type { BubblePosition } from "./MessageBubble";
27
-
28
- export type ConversationScreenProps = {
29
- conversationId: string | null | undefined;
30
- onBack?: () => void;
31
- onClose?: () => void;
32
- onConversationCreated?: (conversationId: string) => void;
33
- };
34
-
35
- // ── date separator ────────────────────────────────────────────────────────────
36
-
37
- function DateSeparator({ label, color }: { label: string; color: string }) {
38
- return (
39
- <View style={styles.dateSep}>
40
- <Text style={[styles.dateSepText, { color }]}>{label}</Text>
41
- </View>
42
- );
43
- }
44
-
45
- function getDateLabel(dateStr: string): string {
46
- const d = new Date(dateStr);
47
- if (isNaN(d.getTime())) return "";
48
- const now = new Date();
49
- const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
50
- const yesterday = new Date(today);
51
- yesterday.setDate(yesterday.getDate() - 1);
52
- const md = new Date(d.getFullYear(), d.getMonth(), d.getDate());
53
- if (md.getTime() === today.getTime()) return "TODAY";
54
- if (md.getTime() === yesterday.getTime()) return "YESTERDAY";
55
- return d.toLocaleDateString([], { day: "2-digit", month: "short" }).toUpperCase();
56
- }
57
-
58
- // ── helpers ───────────────────────────────────────────────────────────────────
59
-
60
- function makeGreetingMessage(greeting: string): any {
61
- return {
62
- _id: "__lc_greeting__",
63
- senderType: "ai",
64
- channelType: "livechat",
65
- livechat: { type: "text", text: { body: greeting } },
66
- };
67
- }
68
-
69
- function getBubblePosition(messages: any[], index: number): { position: BubblePosition; showFooter: boolean } {
70
- const msg = messages[index];
71
- const prev = messages[index - 1];
72
- const next = messages[index + 1];
73
- const prevSame = prev && !prev._isDateSep && prev.channelType !== "log" && prev.senderType === msg.senderType;
74
- const nextSame = next && !next._isDateSep && next.channelType !== "log" && next.senderType === msg.senderType;
75
- let position: BubblePosition = "single";
76
- if (!prevSame && nextSame) position = "first";
77
- else if (prevSame && nextSame) position = "middle";
78
- else if (prevSame && !nextSame) position = "last";
79
- return { position, showFooter: position === "single" || position === "last" };
80
- }
81
-
82
- function buildRenderList(messages: any[], greeting: string): any[] {
83
- const result: any[] = [makeGreetingMessage(greeting)];
84
- // Sort by createdAt ascending mirrors web's transformMessagesToDateSeperators sort
85
- const sorted = [...messages].sort((a, b) => {
86
- const ta = new Date(a?.createdAt || a?.livechat?.timestamp || 0).getTime();
87
- const tb = new Date(b?.createdAt || b?.livechat?.timestamp || 0).getTime();
88
- return ta - tb;
89
- });
90
- let lastDateLabel = "";
91
- for (const m of sorted) {
92
- const dateStr = m?.createdAt || m?.livechat?.timestamp;
93
- if (dateStr) {
94
- const label = getDateLabel(dateStr);
95
- if (label && label !== lastDateLabel) {
96
- result.push({ _isDateSep: true, label, _id: "date-" + label });
97
- lastDateLabel = label;
98
- }
99
- }
100
- result.push(m);
101
- }
102
- return result;
103
- }
104
-
105
- function getRoleLabel(message: any): string {
106
- const type = message?.assigneeType ?? message?.senderType ?? null;
107
- if (type === "ai") return "AI Agent";
108
- if (type === "bot") return "Bot";
109
- if (type === "agent" || type === "user")
110
- return message?.assigneeRole ?? message?.userId?.role ?? "Agent";
111
- if (message?.senderType === "agent")
112
- return message?.assigneeRole ?? message?.userId?.role ?? "Agent";
113
- return "";
114
- }
115
-
116
- function patchConversationShell(prev: any, conversationId: string | null | undefined, incoming: any): any {
117
- if (!prev || !conversationId || !incoming?._id) return prev;
118
- if (String(incoming._id) !== String(conversationId)) return prev;
119
- const nested = prev.conversation;
120
- if (nested != null && typeof nested === "object") {
121
- return { ...prev, conversation: { ...nested, ...incoming } };
122
- }
123
- return { ...prev, ...incoming };
124
- }
125
-
126
- // ── main screen ───────────────────────────────────────────────────────────────
127
-
128
- export function ConversationScreen({
129
- conversationId,
130
- onBack,
131
- onClose,
132
- onConversationCreated,
133
- }: ConversationScreenProps) {
134
- const { theme: t, widgetName, widgetConfig, wsClient, visitorQueryParams } = useLiveChatContext();
135
- const { messages, loading, loadingOlder, hasOlder, loadOlder, markAsRead } = useMessages(conversationId);
136
- const { sendText, sendAttachment, sendInteractive, sending } = useSendMessage();
137
- const flatListRef = useRef<FlatList>(null);
138
-
139
- const settings = widgetConfig?.widgetSettings ?? {};
140
- const greeting = getWidgetGreeting(settings);
141
-
142
- // ── fetch conversation ────────────────────────────────────────────────────
143
- const [conversation, setConversation] = useState<any>(null);
144
- const [listPickerState, setListPickerState] = useState<{ sections: any[]; onSelect: (id: string, title: string) => void } | null>(null);
145
- const prevConvIdRef = useRef<string | null | undefined>(null);
146
-
147
- useEffect(() => {
148
- const isNew = !conversationId || conversationId === "new";
149
- if (isNew || !visitorQueryParams) {
150
- setConversation(null);
151
- prevConvIdRef.current = conversationId;
152
- return;
153
- }
154
- if (prevConvIdRef.current != null && prevConvIdRef.current !== "new" && prevConvIdRef.current !== conversationId) {
155
- setConversation(null);
156
- }
157
- prevConvIdRef.current = conversationId;
158
-
159
- const ac = new AbortController();
160
- (async () => {
161
- try {
162
- const raw = await conversationApi.getConversation(conversationId, {
163
- params: visitorQueryParams,
164
- signal: ac.signal,
165
- });
166
- if (ac.signal.aborted) return;
167
- setConversation(raw?.data ?? raw);
168
- } catch (e: any) {
169
- if (ac.signal.aborted || e?.name === "AbortError") return;
170
- setConversation(null);
171
- }
172
- })();
173
- return () => ac.abort();
174
- }, [conversationId, visitorQueryParams]);
175
-
176
- // ── WS patches ────────────────────────────────────────────────────────────
177
- useEffect(() => {
178
- if (!wsClient || !conversationId || conversationId === "new") return;
179
- const patch = (payload: any) => {
180
- const data = payload?.data ?? payload;
181
- const incoming = data?.conversation;
182
- setConversation((prev: any) => patchConversationShell(prev, conversationId, incoming));
183
- };
184
- const u1 = wsClient.subscribe("conversation_resolved", patch);
185
- const u2 = wsClient.subscribe("conversation_intervened", patch);
186
- const u3 = wsClient.subscribe("conversation_unassigned", patch);
187
- const u4 = wsClient.subscribe("conversation_transferred", patch);
188
- return () => { u1(); u2(); u3(); u4(); };
189
- }, [wsClient, conversationId]);
190
-
191
- const isNewConversation = !conversationId || conversationId === "new";
192
- const convDoc = conversation?.conversation ?? conversation ?? null;
193
- const isResolved = !isNewConversation && !!conversation && convDoc?.status === "resolved";
194
-
195
- const renderList = buildRenderList(messages, greeting);
196
- const renderListRef = useRef<any[]>(renderList);
197
- renderListRef.current = renderList;
198
-
199
- useEffect(() => { if (conversationId && conversationId !== "new") markAsRead(); }, [conversationId, markAsRead]);
200
-
201
- useEffect(() => {
202
- if (renderList.length > 0) {
203
- setTimeout(() => flatListRef.current?.scrollToEnd({ animated: true }), 100);
204
- }
205
- }, [renderList.length]);
206
-
207
- const handleSend = useCallback(async (text: string, uploadedAttachments?: UploadedAttachment[]) => {
208
- if (uploadedAttachments?.length) {
209
- let activeId: string | null | undefined = conversationId;
210
- for (let i = 0; i < uploadedAttachments.length; i++) {
211
- const att = uploadedAttachments[i];
212
- const caption = i === 0 && text ? text : undefined;
213
- const returnedId = await sendAttachment(
214
- { fileId: att.fileId, fileUrl: att.fileUrl, fileName: att.fileName, fileExtension: att.fileExtension, type: att.type, caption },
215
- activeId === "new" ? null : activeId
216
- );
217
- if ((!activeId || activeId === "new") && returnedId) {
218
- activeId = returnedId;
219
- onConversationCreated?.(returnedId);
220
- }
221
- }
222
- return;
223
- }
224
- const returnedId = await sendText(text, isNewConversation ? null : conversationId);
225
- if (isNewConversation && returnedId) onConversationCreated?.(returnedId);
226
- }, [conversationId, sendText, sendAttachment, onConversationCreated]);
227
-
228
- const renderItem = useCallback(({ item, index }: { item: any; index: number }) => {
229
- if (item._isDateSep) {
230
- return <DateSeparator label={item.label} color={t.colors.textMuted} />;
231
- }
232
- if (item.channelType === "log") {
233
- return <LogMessage message={item} />;
234
- }
235
- const { position, showFooter } = getBubblePosition(renderListRef.current, index);
236
- return (
237
- <MessageBubble
238
- message={item}
239
- position={position}
240
- showFooter={showFooter}
241
- senderName={item?.userId?.name ?? item?.senderName ?? widgetName}
242
- roleLabel={getRoleLabel(item)}
243
- onInteractiveReply={(payload: InteractiveReplyPayload | string) => {
244
- if (typeof payload === "string") {
245
- handleSend(payload);
246
- } else {
247
- sendInteractive(
248
- { type: payload.type, id: payload.id, title: payload.title },
249
- isNewConversation ? null : conversationId
250
- );
251
- }
252
- }}
253
- onShowListPicker={(sections, onSelect) => setListPickerState({ sections, onSelect })}
254
- />
255
- );
256
- }, [t, widgetName, handleSend]);
257
-
258
- return (
259
- <KeyboardAvoidingView
260
- style={[styles.flex, { backgroundColor: t.colors.background }]}
261
- behavior={Platform.OS === "ios" ? "padding" : "height"}
262
- keyboardVerticalOffset={0}
263
- >
264
- <ConversationHeader
265
- conversation={isNewConversation ? null : conversation}
266
- isLoading={!isNewConversation && conversation === null}
267
- onBack={onBack}
268
- onClose={onClose}
269
- />
270
-
271
- {loadingOlder && (
272
- <ActivityIndicator color={t.colors.brand} style={{ marginTop: 8 }} />
273
- )}
274
-
275
- <FlatList
276
- ref={flatListRef}
277
- data={renderList}
278
- keyExtractor={(item, i) => item?._id ?? String(i)}
279
- renderItem={renderItem}
280
- style={styles.flex}
281
- contentContainerStyle={{ paddingVertical: 8 }}
282
- onEndReached={hasOlder ? loadOlder : undefined}
283
- onEndReachedThreshold={0.2}
284
- maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
285
- onContentSizeChange={() => flatListRef.current?.scrollToEnd({ animated: true })}
286
- ListHeaderComponent={
287
- loading && messages.length === 0 ? (
288
- <View style={styles.center}>
289
- <ActivityIndicator color={t.colors.brand} size="large" style={{ marginVertical: 24 }} />
290
- </View>
291
- ) : null
292
- }
293
- ListEmptyComponent={
294
- !loading ? (
295
- <View style={[styles.flex, styles.center]}>
296
- <Text style={{ color: t.colors.textMuted, fontSize: t.fontSizes.md }}>
297
- Start the conversation
298
- </Text>
299
- </View>
300
- ) : null
301
- }
302
- />
303
-
304
- <MessageComposer
305
- onSend={handleSend}
306
- disabled={isResolved || sending}
307
- placeholder={isResolved ? "This conversation is resolved" : "Message…"}
308
- />
309
-
310
- {listPickerState && (
311
- <View style={[styles.listPicker, { backgroundColor: t.colors.surface }]}>
312
- <View style={[styles.listPickerHeader, { borderBottomColor: t.colors.border }]}>
313
- <Text style={[styles.listPickerTitle, { color: t.colors.text }]}>Select an option</Text>
314
- <TouchableOpacity onPress={() => setListPickerState(null)}>
315
- <Text style={{ color: t.colors.textMuted, fontSize: 20 }}>×</Text>
316
- </TouchableOpacity>
317
- </View>
318
- <FlatList
319
- data={listPickerState.sections}
320
- keyExtractor={(_, i) => String(i)}
321
- renderItem={({ item: section }) => (
322
- <View>
323
- {section.title ? (
324
- <Text style={[styles.listPickerSectionTitle, { color: t.colors.textMuted }]}>{section.title}</Text>
325
- ) : null}
326
- {(section.rows ?? []).map((row: any) => (
327
- <TouchableOpacity
328
- key={row.id}
329
- onPress={() => { listPickerState.onSelect(row.id, row.title); setListPickerState(null); }}
330
- style={[styles.listPickerRow, { borderBottomColor: t.colors.border }]}
331
- >
332
- <Text style={[styles.listPickerRowTitle, { color: t.colors.text }]}>{row.title}</Text>
333
- {row.description ? <Text style={[styles.listPickerRowDesc, { color: t.colors.textMuted }]}>{row.description}</Text> : null}
334
- </TouchableOpacity>
335
- ))}
336
- </View>
337
- )}
338
- />
339
- </View>
340
- )}
341
-
342
- <Text style={[styles.poweredBy, { color: t.colors.textMuted }]}>
343
- {POWERED_BY_TEXT}
344
- </Text>
345
- </KeyboardAvoidingView>
346
- );
347
- }
348
-
349
- const styles = StyleSheet.create({
350
- flex: { flex: 1 },
351
- center: { alignItems: "center", justifyContent: "center" },
352
- dateSep: { alignItems: "center", paddingVertical: 10 },
353
- dateSepText: { fontSize: 11, fontWeight: "600", letterSpacing: 0.5 },
354
- poweredBy: { textAlign: "center", fontSize: 11, paddingVertical: 8 },
355
- listPicker: { position: "absolute", inset: 0, top: 0, left: 0, right: 0, bottom: 0, zIndex: 20 },
356
- listPickerHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: StyleSheet.hairlineWidth },
357
- listPickerTitle: { fontSize: 14, fontWeight: "600" },
358
- listPickerSectionTitle: { fontSize: 11, fontWeight: "600", textTransform: "uppercase", letterSpacing: 0.5, paddingHorizontal: 16, paddingTop: 12, paddingBottom: 4 },
359
- listPickerRow: { paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: StyleSheet.hairlineWidth },
360
- listPickerRowTitle: { fontSize: 14, fontWeight: "500" },
361
- listPickerRowDesc: { fontSize: 12, marginTop: 2 },
362
- });
1
+ import React, { useCallback, useEffect, useRef, useState } from "react";
2
+ import {
3
+ ActivityIndicator,
4
+ FlatList,
5
+ StyleSheet,
6
+ Text,
7
+ TouchableOpacity,
8
+ View,
9
+ } from "react-native";
10
+ import { KeyboardAvoidingView } from "react-native-keyboard-controller";
11
+ import { useLiveChatContext } from "../../provider/LiveChatContext";
12
+ import { conversationApi } from "../../api/conversation-api";
13
+ import { useMessages } from "../../hooks/use-messages";
14
+ import { useSendMessage } from "../../hooks/use-send-message";
15
+ import { ConversationHeader } from "./ConversationHeader";
16
+ import { MessageBubble } from "./MessageBubble";
17
+ import { LogMessage } from "./LogMessage";
18
+ import { MessageComposer } from "./MessageComposer";
19
+ import type { UploadedAttachment } from "./MessageComposer";
20
+ import type { InteractiveReplyPayload } from "./LivechatMessageRenderer";
21
+ import {
22
+ POWERED_BY_TEXT,
23
+ getWidgetGreeting,
24
+ } from "../../ui/theme";
25
+ import type { BubblePosition } from "./MessageBubble";
26
+
27
+ export type ConversationScreenProps = {
28
+ conversationId: string | null | undefined;
29
+ onBack?: () => void;
30
+ onClose?: () => void;
31
+ onConversationCreated?: (conversationId: string) => void;
32
+ };
33
+
34
+ // ── date separator ────────────────────────────────────────────────────────────
35
+
36
+ function DateSeparator({ label, color }: { label: string; color: string }) {
37
+ return (
38
+ <View style={styles.dateSep}>
39
+ <Text style={[styles.dateSepText, { color }]}>{label}</Text>
40
+ </View>
41
+ );
42
+ }
43
+
44
+ function getDateLabel(dateStr: string): string {
45
+ const d = new Date(dateStr);
46
+ if (isNaN(d.getTime())) return "";
47
+ const now = new Date();
48
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
49
+ const yesterday = new Date(today);
50
+ yesterday.setDate(yesterday.getDate() - 1);
51
+ const md = new Date(d.getFullYear(), d.getMonth(), d.getDate());
52
+ if (md.getTime() === today.getTime()) return "TODAY";
53
+ if (md.getTime() === yesterday.getTime()) return "YESTERDAY";
54
+ return d.toLocaleDateString([], { day: "2-digit", month: "short" }).toUpperCase();
55
+ }
56
+
57
+ // ── helpers ───────────────────────────────────────────────────────────────────
58
+
59
+ function makeGreetingMessage(greeting: string): any {
60
+ return {
61
+ _id: "__lc_greeting__",
62
+ senderType: "ai",
63
+ channelType: "livechat",
64
+ livechat: { type: "text", text: { body: greeting } },
65
+ };
66
+ }
67
+
68
+ function getBubblePosition(messages: any[], index: number): { position: BubblePosition; showFooter: boolean } {
69
+ const msg = messages[index];
70
+ const prev = messages[index - 1];
71
+ const next = messages[index + 1];
72
+ const prevSame = prev && !prev._isDateSep && prev.channelType !== "log" && prev.senderType === msg.senderType;
73
+ const nextSame = next && !next._isDateSep && next.channelType !== "log" && next.senderType === msg.senderType;
74
+ let position: BubblePosition = "single";
75
+ if (!prevSame && nextSame) position = "first";
76
+ else if (prevSame && nextSame) position = "middle";
77
+ else if (prevSame && !nextSame) position = "last";
78
+ return { position, showFooter: position === "single" || position === "last" };
79
+ }
80
+
81
+ function buildRenderList(messages: any[], greeting: string): any[] {
82
+ const result: any[] = [makeGreetingMessage(greeting)];
83
+ // Sort by createdAt ascending — mirrors web's transformMessagesToDateSeperators sort
84
+ const sorted = [...messages].sort((a, b) => {
85
+ const ta = new Date(a?.createdAt || a?.livechat?.timestamp || 0).getTime();
86
+ const tb = new Date(b?.createdAt || b?.livechat?.timestamp || 0).getTime();
87
+ return ta - tb;
88
+ });
89
+ let lastDateLabel = "";
90
+ for (const m of sorted) {
91
+ const dateStr = m?.createdAt || m?.livechat?.timestamp;
92
+ if (dateStr) {
93
+ const label = getDateLabel(dateStr);
94
+ if (label && label !== lastDateLabel) {
95
+ result.push({ _isDateSep: true, label, _id: "date-" + label });
96
+ lastDateLabel = label;
97
+ }
98
+ }
99
+ result.push(m);
100
+ }
101
+ return result;
102
+ }
103
+
104
+ function getRoleLabel(message: any): string {
105
+ const type = message?.assigneeType ?? message?.senderType ?? null;
106
+ if (type === "ai") return "AI Agent";
107
+ if (type === "bot") return "Bot";
108
+ if (type === "agent" || type === "user")
109
+ return message?.assigneeRole ?? message?.userId?.role ?? "Agent";
110
+ if (message?.senderType === "agent")
111
+ return message?.assigneeRole ?? message?.userId?.role ?? "Agent";
112
+ return "";
113
+ }
114
+
115
+ function patchConversationShell(prev: any, conversationId: string | null | undefined, incoming: any): any {
116
+ if (!prev || !conversationId || !incoming?._id) return prev;
117
+ if (String(incoming._id) !== String(conversationId)) return prev;
118
+ const nested = prev.conversation;
119
+ if (nested != null && typeof nested === "object") {
120
+ return { ...prev, conversation: { ...nested, ...incoming } };
121
+ }
122
+ return { ...prev, ...incoming };
123
+ }
124
+
125
+ // ── main screen ───────────────────────────────────────────────────────────────
126
+
127
+ export function ConversationScreen({
128
+ conversationId,
129
+ onBack,
130
+ onClose,
131
+ onConversationCreated,
132
+ }: ConversationScreenProps) {
133
+ const { theme: t, widgetName, widgetConfig, wsClient, visitorQueryParams, setActiveConversationId } = useLiveChatContext();
134
+ const { messages, loading, loadingOlder, hasOlder, loadOlder, markAsRead } = useMessages(conversationId);
135
+ const { sendText, sendAttachment, sendInteractive, sending } = useSendMessage();
136
+ const flatListRef = useRef<FlatList>(null);
137
+
138
+ const settings = widgetConfig?.widgetSettings ?? {};
139
+ const greeting = getWidgetGreeting(settings);
140
+
141
+ // ── fetch conversation ────────────────────────────────────────────────────
142
+ const [conversation, setConversation] = useState<any>(null);
143
+ const [listPickerState, setListPickerState] = useState<{ sections: any[]; onSelect: (id: string, title: string) => void } | null>(null);
144
+ const prevConvIdRef = useRef<string | null | undefined>(null);
145
+
146
+ useEffect(() => {
147
+ const isNew = !conversationId || conversationId === "new";
148
+ if (isNew || !visitorQueryParams) {
149
+ setConversation(null);
150
+ prevConvIdRef.current = conversationId;
151
+ return;
152
+ }
153
+ if (prevConvIdRef.current != null && prevConvIdRef.current !== "new" && prevConvIdRef.current !== conversationId) {
154
+ setConversation(null);
155
+ }
156
+ prevConvIdRef.current = conversationId;
157
+
158
+ const ac = new AbortController();
159
+ (async () => {
160
+ try {
161
+ const raw = await conversationApi.getConversation(conversationId, {
162
+ params: visitorQueryParams,
163
+ signal: ac.signal,
164
+ });
165
+ if (ac.signal.aborted) return;
166
+ setConversation(raw?.data ?? raw);
167
+ } catch (e: any) {
168
+ if (ac.signal.aborted || e?.name === "AbortError") return;
169
+ setConversation(null);
170
+ }
171
+ })();
172
+ return () => ac.abort();
173
+ }, [conversationId, visitorQueryParams]);
174
+
175
+ // ── WS patches ────────────────────────────────────────────────────────────
176
+ useEffect(() => {
177
+ if (!wsClient || !conversationId || conversationId === "new") return;
178
+ const patch = (payload: any) => {
179
+ const data = payload?.data ?? payload;
180
+ const incoming = data?.conversation;
181
+ setConversation((prev: any) => patchConversationShell(prev, conversationId, incoming));
182
+ };
183
+ const u1 = wsClient.subscribe("conversation_resolved", patch);
184
+ const u2 = wsClient.subscribe("conversation_intervened", patch);
185
+ const u3 = wsClient.subscribe("conversation_unassigned", patch);
186
+ const u4 = wsClient.subscribe("conversation_transferred", patch);
187
+ return () => { u1(); u2(); u3(); u4(); };
188
+ }, [wsClient, conversationId]);
189
+
190
+ const isNewConversation = !conversationId || conversationId === "new";
191
+ const convDoc = conversation?.conversation ?? conversation ?? null;
192
+ const isResolved = !isNewConversation && !!conversation && convDoc?.status === "resolved";
193
+
194
+ const renderList = buildRenderList(messages, greeting);
195
+ const renderListRef = useRef<any[]>(renderList);
196
+ renderListRef.current = renderList;
197
+
198
+ useEffect(() => { if (conversationId && conversationId !== "new") markAsRead(); }, [conversationId, markAsRead]);
199
+
200
+ useEffect(() => {
201
+ if (!conversationId || conversationId === "new") return;
202
+ setActiveConversationId(conversationId);
203
+ return () => setActiveConversationId(null);
204
+ }, [conversationId, setActiveConversationId]);
205
+
206
+ useEffect(() => {
207
+ if (renderList.length > 0) {
208
+ setTimeout(() => flatListRef.current?.scrollToEnd({ animated: true }), 100);
209
+ }
210
+ }, [renderList.length]);
211
+
212
+ // Throws on failure so MessageComposer can preserve the draft and show the error.
213
+ const handleSend = useCallback(async (text: string, uploadedAttachments?: UploadedAttachment[]) => {
214
+ if (uploadedAttachments?.length) {
215
+ let activeId: string | null | undefined = conversationId;
216
+ for (let i = 0; i < uploadedAttachments.length; i++) {
217
+ const att = uploadedAttachments[i];
218
+ const caption = i === 0 && text ? text : undefined;
219
+ // sendAttachment throws on failure — let it propagate
220
+ const returnedId = await sendAttachment(
221
+ { fileId: att.fileId, fileUrl: att.fileUrl, fileName: att.fileName, fileExtension: att.fileExtension, type: att.type, caption },
222
+ activeId === "new" ? null : activeId
223
+ );
224
+ if ((!activeId || activeId === "new") && returnedId) {
225
+ activeId = returnedId;
226
+ onConversationCreated?.(returnedId);
227
+ }
228
+ }
229
+ return;
230
+ }
231
+ // sendText throws on failure — let it propagate
232
+ const returnedId = await sendText(text, isNewConversation ? null : conversationId);
233
+ if (isNewConversation && returnedId) onConversationCreated?.(returnedId);
234
+ }, [conversationId, isNewConversation, sendText, sendAttachment, onConversationCreated]);
235
+
236
+ const renderItem = useCallback(({ item, index }: { item: any; index: number }) => {
237
+ if (item._isDateSep) {
238
+ return <DateSeparator label={item.label} color={t.colors.textMuted} />;
239
+ }
240
+ if (item.channelType === "log") {
241
+ return <LogMessage message={item} />;
242
+ }
243
+ const { position, showFooter } = getBubblePosition(renderListRef.current, index);
244
+ return (
245
+ <MessageBubble
246
+ message={item}
247
+ position={position}
248
+ showFooter={showFooter}
249
+ senderName={item?.userId?.name ?? item?.senderName ?? widgetName}
250
+ roleLabel={getRoleLabel(item)}
251
+ onInteractiveReply={(payload: InteractiveReplyPayload | string) => {
252
+ if (typeof payload === "string") {
253
+ handleSend(payload);
254
+ } else {
255
+ sendInteractive(
256
+ { type: payload.type, id: payload.id, title: payload.title },
257
+ isNewConversation ? null : conversationId
258
+ );
259
+ }
260
+ }}
261
+ onShowListPicker={(sections, onSelect) => setListPickerState({ sections, onSelect })}
262
+ />
263
+ );
264
+ }, [t, widgetName, handleSend]);
265
+
266
+ return (
267
+ <KeyboardAvoidingView
268
+ style={[styles.flex, { backgroundColor: t.colors.background }]}
269
+ behavior="padding"
270
+ >
271
+ <ConversationHeader
272
+ conversation={isNewConversation ? null : conversation}
273
+ isLoading={!isNewConversation && conversation === null}
274
+ onBack={onBack}
275
+ onClose={onClose}
276
+ />
277
+
278
+ {loadingOlder && (
279
+ <ActivityIndicator color={t.colors.brand} style={{ marginTop: 8 }} />
280
+ )}
281
+
282
+ <FlatList
283
+ ref={flatListRef}
284
+ data={renderList}
285
+ keyExtractor={(item, i) => item?._id ?? String(i)}
286
+ renderItem={renderItem}
287
+ style={styles.flex}
288
+ contentContainerStyle={{ paddingVertical: 8 }}
289
+ onEndReached={hasOlder ? loadOlder : undefined}
290
+ onEndReachedThreshold={0.2}
291
+ maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
292
+ onContentSizeChange={() => flatListRef.current?.scrollToEnd({ animated: true })}
293
+ ListHeaderComponent={
294
+ loading && messages.length === 0 ? (
295
+ <View style={styles.center}>
296
+ <ActivityIndicator color={t.colors.brand} size="large" style={{ marginVertical: 24 }} />
297
+ </View>
298
+ ) : null
299
+ }
300
+ ListEmptyComponent={
301
+ !loading ? (
302
+ <View style={[styles.flex, styles.center]}>
303
+ <Text style={{ color: t.colors.textMuted, fontSize: t.fontSizes.md }}>
304
+ Start the conversation
305
+ </Text>
306
+ </View>
307
+ ) : null
308
+ }
309
+ />
310
+
311
+ <MessageComposer
312
+ onSend={handleSend}
313
+ disabled={isResolved || sending}
314
+ placeholder={isResolved ? "This conversation is resolved" : "Message…"}
315
+ />
316
+
317
+ {listPickerState && (
318
+ <View style={[styles.listPicker, { backgroundColor: t.colors.surface }]}>
319
+ <View style={[styles.listPickerHeader, { borderBottomColor: t.colors.border }]}>
320
+ <Text style={[styles.listPickerTitle, { color: t.colors.text }]}>Select an option</Text>
321
+ <TouchableOpacity onPress={() => setListPickerState(null)}>
322
+ <Text style={{ color: t.colors.textMuted, fontSize: 20 }}>×</Text>
323
+ </TouchableOpacity>
324
+ </View>
325
+ <FlatList
326
+ data={listPickerState.sections}
327
+ keyExtractor={(_, i) => String(i)}
328
+ renderItem={({ item: section }) => (
329
+ <View>
330
+ {section.title ? (
331
+ <Text style={[styles.listPickerSectionTitle, { color: t.colors.textMuted }]}>{section.title}</Text>
332
+ ) : null}
333
+ {(section.rows ?? []).map((row: any) => (
334
+ <TouchableOpacity
335
+ key={row.id}
336
+ onPress={() => { listPickerState.onSelect(row.id, row.title); setListPickerState(null); }}
337
+ style={[styles.listPickerRow, { borderBottomColor: t.colors.border }]}
338
+ >
339
+ <Text style={[styles.listPickerRowTitle, { color: t.colors.text }]}>{row.title}</Text>
340
+ {row.description ? <Text style={[styles.listPickerRowDesc, { color: t.colors.textMuted }]}>{row.description}</Text> : null}
341
+ </TouchableOpacity>
342
+ ))}
343
+ </View>
344
+ )}
345
+ />
346
+ </View>
347
+ )}
348
+
349
+ <Text style={[styles.poweredBy, { color: t.colors.textMuted }]}>
350
+ {POWERED_BY_TEXT}
351
+ </Text>
352
+ </KeyboardAvoidingView>
353
+ );
354
+ }
355
+
356
+ const styles = StyleSheet.create({
357
+ flex: { flex: 1 },
358
+ center: { alignItems: "center", justifyContent: "center" },
359
+ dateSep: { alignItems: "center", paddingVertical: 10 },
360
+ dateSepText: { fontSize: 11, fontWeight: "600", letterSpacing: 0.5 },
361
+ poweredBy: { textAlign: "center", fontSize: 11, paddingVertical: 8 },
362
+ listPicker: { position: "absolute", inset: 0, top: 0, left: 0, right: 0, bottom: 0, zIndex: 20 },
363
+ listPickerHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: StyleSheet.hairlineWidth },
364
+ listPickerTitle: { fontSize: 14, fontWeight: "600" },
365
+ listPickerSectionTitle: { fontSize: 11, fontWeight: "600", textTransform: "uppercase", letterSpacing: 0.5, paddingHorizontal: 16, paddingTop: 12, paddingBottom: 4 },
366
+ listPickerRow: { paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: StyleSheet.hairlineWidth },
367
+ listPickerRowTitle: { fontSize: 14, fontWeight: "500" },
368
+ listPickerRowDesc: { fontSize: 12, marginTop: 2 },
369
+ });