turbodesk-livechat-react-native 0.1.0-alpha.23 → 0.1.0-alpha.25

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.
@@ -1,364 +1,364 @@
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
- // Throws on failure so MessageComposer can preserve the draft and show the error.
208
- const handleSend = useCallback(async (text: string, uploadedAttachments?: UploadedAttachment[]) => {
209
- if (uploadedAttachments?.length) {
210
- let activeId: string | null | undefined = conversationId;
211
- for (let i = 0; i < uploadedAttachments.length; i++) {
212
- const att = uploadedAttachments[i];
213
- const caption = i === 0 && text ? text : undefined;
214
- // sendAttachment throws on failure — let it propagate
215
- const returnedId = await sendAttachment(
216
- { fileId: att.fileId, fileUrl: att.fileUrl, fileName: att.fileName, fileExtension: att.fileExtension, type: att.type, caption },
217
- activeId === "new" ? null : activeId
218
- );
219
- if ((!activeId || activeId === "new") && returnedId) {
220
- activeId = returnedId;
221
- onConversationCreated?.(returnedId);
222
- }
223
- }
224
- return;
225
- }
226
- // sendText throws on failure — let it propagate
227
- const returnedId = await sendText(text, isNewConversation ? null : conversationId);
228
- if (isNewConversation && returnedId) onConversationCreated?.(returnedId);
229
- }, [conversationId, isNewConversation, sendText, sendAttachment, onConversationCreated]);
230
-
231
- const renderItem = useCallback(({ item, index }: { item: any; index: number }) => {
232
- if (item._isDateSep) {
233
- return <DateSeparator label={item.label} color={t.colors.textMuted} />;
234
- }
235
- if (item.channelType === "log") {
236
- return <LogMessage message={item} />;
237
- }
238
- const { position, showFooter } = getBubblePosition(renderListRef.current, index);
239
- return (
240
- <MessageBubble
241
- message={item}
242
- position={position}
243
- showFooter={showFooter}
244
- senderName={item?.userId?.name ?? item?.senderName ?? widgetName}
245
- roleLabel={getRoleLabel(item)}
246
- onInteractiveReply={(payload: InteractiveReplyPayload | string) => {
247
- if (typeof payload === "string") {
248
- handleSend(payload);
249
- } else {
250
- sendInteractive(
251
- { type: payload.type, id: payload.id, title: payload.title },
252
- isNewConversation ? null : conversationId
253
- );
254
- }
255
- }}
256
- onShowListPicker={(sections, onSelect) => setListPickerState({ sections, onSelect })}
257
- />
258
- );
259
- }, [t, widgetName, handleSend]);
260
-
261
- return (
262
- <KeyboardAvoidingView
263
- style={[styles.flex, { backgroundColor: t.colors.background }]}
264
- behavior={Platform.OS === "ios" ? "padding" : "height"}
265
- >
266
- <ConversationHeader
267
- conversation={isNewConversation ? null : conversation}
268
- isLoading={!isNewConversation && conversation === null}
269
- onBack={onBack}
270
- onClose={onClose}
271
- />
272
-
273
- {loadingOlder && (
274
- <ActivityIndicator color={t.colors.brand} style={{ marginTop: 8 }} />
275
- )}
276
-
277
- <FlatList
278
- ref={flatListRef}
279
- data={renderList}
280
- keyExtractor={(item, i) => item?._id ?? String(i)}
281
- renderItem={renderItem}
282
- style={styles.flex}
283
- contentContainerStyle={{ paddingVertical: 8 }}
284
- onEndReached={hasOlder ? loadOlder : undefined}
285
- onEndReachedThreshold={0.2}
286
- maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
287
- onContentSizeChange={() => flatListRef.current?.scrollToEnd({ animated: true })}
288
- ListHeaderComponent={
289
- loading && messages.length === 0 ? (
290
- <View style={styles.center}>
291
- <ActivityIndicator color={t.colors.brand} size="large" style={{ marginVertical: 24 }} />
292
- </View>
293
- ) : null
294
- }
295
- ListEmptyComponent={
296
- !loading ? (
297
- <View style={[styles.flex, styles.center]}>
298
- <Text style={{ color: t.colors.textMuted, fontSize: t.fontSizes.md }}>
299
- Start the conversation
300
- </Text>
301
- </View>
302
- ) : null
303
- }
304
- />
305
-
306
- <MessageComposer
307
- onSend={handleSend}
308
- disabled={isResolved || sending}
309
- placeholder={isResolved ? "This conversation is resolved" : "Message…"}
310
- />
311
-
312
- {listPickerState && (
313
- <View style={[styles.listPicker, { backgroundColor: t.colors.surface }]}>
314
- <View style={[styles.listPickerHeader, { borderBottomColor: t.colors.border }]}>
315
- <Text style={[styles.listPickerTitle, { color: t.colors.text }]}>Select an option</Text>
316
- <TouchableOpacity onPress={() => setListPickerState(null)}>
317
- <Text style={{ color: t.colors.textMuted, fontSize: 20 }}>×</Text>
318
- </TouchableOpacity>
319
- </View>
320
- <FlatList
321
- data={listPickerState.sections}
322
- keyExtractor={(_, i) => String(i)}
323
- renderItem={({ item: section }) => (
324
- <View>
325
- {section.title ? (
326
- <Text style={[styles.listPickerSectionTitle, { color: t.colors.textMuted }]}>{section.title}</Text>
327
- ) : null}
328
- {(section.rows ?? []).map((row: any) => (
329
- <TouchableOpacity
330
- key={row.id}
331
- onPress={() => { listPickerState.onSelect(row.id, row.title); setListPickerState(null); }}
332
- style={[styles.listPickerRow, { borderBottomColor: t.colors.border }]}
333
- >
334
- <Text style={[styles.listPickerRowTitle, { color: t.colors.text }]}>{row.title}</Text>
335
- {row.description ? <Text style={[styles.listPickerRowDesc, { color: t.colors.textMuted }]}>{row.description}</Text> : null}
336
- </TouchableOpacity>
337
- ))}
338
- </View>
339
- )}
340
- />
341
- </View>
342
- )}
343
-
344
- <Text style={[styles.poweredBy, { color: t.colors.textMuted }]}>
345
- {POWERED_BY_TEXT}
346
- </Text>
347
- </KeyboardAvoidingView>
348
- );
349
- }
350
-
351
- const styles = StyleSheet.create({
352
- flex: { flex: 1 },
353
- center: { alignItems: "center", justifyContent: "center" },
354
- dateSep: { alignItems: "center", paddingVertical: 10 },
355
- dateSepText: { fontSize: 11, fontWeight: "600", letterSpacing: 0.5 },
356
- poweredBy: { textAlign: "center", fontSize: 11, paddingVertical: 8 },
357
- listPicker: { position: "absolute", inset: 0, top: 0, left: 0, right: 0, bottom: 0, zIndex: 20 },
358
- listPickerHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: StyleSheet.hairlineWidth },
359
- listPickerTitle: { fontSize: 14, fontWeight: "600" },
360
- listPickerSectionTitle: { fontSize: 11, fontWeight: "600", textTransform: "uppercase", letterSpacing: 0.5, paddingHorizontal: 16, paddingTop: 12, paddingBottom: 4 },
361
- listPickerRow: { paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: StyleSheet.hairlineWidth },
362
- listPickerRowTitle: { fontSize: 14, fontWeight: "500" },
363
- listPickerRowDesc: { fontSize: 12, marginTop: 2 },
364
- });
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
+ // Throws on failure so MessageComposer can preserve the draft and show the error.
208
+ const handleSend = useCallback(async (text: string, uploadedAttachments?: UploadedAttachment[]) => {
209
+ if (uploadedAttachments?.length) {
210
+ let activeId: string | null | undefined = conversationId;
211
+ for (let i = 0; i < uploadedAttachments.length; i++) {
212
+ const att = uploadedAttachments[i];
213
+ const caption = i === 0 && text ? text : undefined;
214
+ // sendAttachment throws on failure — let it propagate
215
+ const returnedId = await sendAttachment(
216
+ { fileId: att.fileId, fileUrl: att.fileUrl, fileName: att.fileName, fileExtension: att.fileExtension, type: att.type, caption },
217
+ activeId === "new" ? null : activeId
218
+ );
219
+ if ((!activeId || activeId === "new") && returnedId) {
220
+ activeId = returnedId;
221
+ onConversationCreated?.(returnedId);
222
+ }
223
+ }
224
+ return;
225
+ }
226
+ // sendText throws on failure — let it propagate
227
+ const returnedId = await sendText(text, isNewConversation ? null : conversationId);
228
+ if (isNewConversation && returnedId) onConversationCreated?.(returnedId);
229
+ }, [conversationId, isNewConversation, sendText, sendAttachment, onConversationCreated]);
230
+
231
+ const renderItem = useCallback(({ item, index }: { item: any; index: number }) => {
232
+ if (item._isDateSep) {
233
+ return <DateSeparator label={item.label} color={t.colors.textMuted} />;
234
+ }
235
+ if (item.channelType === "log") {
236
+ return <LogMessage message={item} />;
237
+ }
238
+ const { position, showFooter } = getBubblePosition(renderListRef.current, index);
239
+ return (
240
+ <MessageBubble
241
+ message={item}
242
+ position={position}
243
+ showFooter={showFooter}
244
+ senderName={item?.userId?.name ?? item?.senderName ?? widgetName}
245
+ roleLabel={getRoleLabel(item)}
246
+ onInteractiveReply={(payload: InteractiveReplyPayload | string) => {
247
+ if (typeof payload === "string") {
248
+ handleSend(payload);
249
+ } else {
250
+ sendInteractive(
251
+ { type: payload.type, id: payload.id, title: payload.title },
252
+ isNewConversation ? null : conversationId
253
+ );
254
+ }
255
+ }}
256
+ onShowListPicker={(sections, onSelect) => setListPickerState({ sections, onSelect })}
257
+ />
258
+ );
259
+ }, [t, widgetName, handleSend]);
260
+
261
+ return (
262
+ <KeyboardAvoidingView
263
+ style={[styles.flex, { backgroundColor: t.colors.background }]}
264
+ behavior={Platform.OS === "ios" ? "padding" : "height"}
265
+ >
266
+ <ConversationHeader
267
+ conversation={isNewConversation ? null : conversation}
268
+ isLoading={!isNewConversation && conversation === null}
269
+ onBack={onBack}
270
+ onClose={onClose}
271
+ />
272
+
273
+ {loadingOlder && (
274
+ <ActivityIndicator color={t.colors.brand} style={{ marginTop: 8 }} />
275
+ )}
276
+
277
+ <FlatList
278
+ ref={flatListRef}
279
+ data={renderList}
280
+ keyExtractor={(item, i) => item?._id ?? String(i)}
281
+ renderItem={renderItem}
282
+ style={styles.flex}
283
+ contentContainerStyle={{ paddingVertical: 8 }}
284
+ onEndReached={hasOlder ? loadOlder : undefined}
285
+ onEndReachedThreshold={0.2}
286
+ maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
287
+ onContentSizeChange={() => flatListRef.current?.scrollToEnd({ animated: true })}
288
+ ListHeaderComponent={
289
+ loading && messages.length === 0 ? (
290
+ <View style={styles.center}>
291
+ <ActivityIndicator color={t.colors.brand} size="large" style={{ marginVertical: 24 }} />
292
+ </View>
293
+ ) : null
294
+ }
295
+ ListEmptyComponent={
296
+ !loading ? (
297
+ <View style={[styles.flex, styles.center]}>
298
+ <Text style={{ color: t.colors.textMuted, fontSize: t.fontSizes.md }}>
299
+ Start the conversation
300
+ </Text>
301
+ </View>
302
+ ) : null
303
+ }
304
+ />
305
+
306
+ <MessageComposer
307
+ onSend={handleSend}
308
+ disabled={isResolved || sending}
309
+ placeholder={isResolved ? "This conversation is resolved" : "Message…"}
310
+ />
311
+
312
+ {listPickerState && (
313
+ <View style={[styles.listPicker, { backgroundColor: t.colors.surface }]}>
314
+ <View style={[styles.listPickerHeader, { borderBottomColor: t.colors.border }]}>
315
+ <Text style={[styles.listPickerTitle, { color: t.colors.text }]}>Select an option</Text>
316
+ <TouchableOpacity onPress={() => setListPickerState(null)}>
317
+ <Text style={{ color: t.colors.textMuted, fontSize: 20 }}>×</Text>
318
+ </TouchableOpacity>
319
+ </View>
320
+ <FlatList
321
+ data={listPickerState.sections}
322
+ keyExtractor={(_, i) => String(i)}
323
+ renderItem={({ item: section }) => (
324
+ <View>
325
+ {section.title ? (
326
+ <Text style={[styles.listPickerSectionTitle, { color: t.colors.textMuted }]}>{section.title}</Text>
327
+ ) : null}
328
+ {(section.rows ?? []).map((row: any) => (
329
+ <TouchableOpacity
330
+ key={row.id}
331
+ onPress={() => { listPickerState.onSelect(row.id, row.title); setListPickerState(null); }}
332
+ style={[styles.listPickerRow, { borderBottomColor: t.colors.border }]}
333
+ >
334
+ <Text style={[styles.listPickerRowTitle, { color: t.colors.text }]}>{row.title}</Text>
335
+ {row.description ? <Text style={[styles.listPickerRowDesc, { color: t.colors.textMuted }]}>{row.description}</Text> : null}
336
+ </TouchableOpacity>
337
+ ))}
338
+ </View>
339
+ )}
340
+ />
341
+ </View>
342
+ )}
343
+
344
+ <Text style={[styles.poweredBy, { color: t.colors.textMuted }]}>
345
+ {POWERED_BY_TEXT}
346
+ </Text>
347
+ </KeyboardAvoidingView>
348
+ );
349
+ }
350
+
351
+ const styles = StyleSheet.create({
352
+ flex: { flex: 1 },
353
+ center: { alignItems: "center", justifyContent: "center" },
354
+ dateSep: { alignItems: "center", paddingVertical: 10 },
355
+ dateSepText: { fontSize: 11, fontWeight: "600", letterSpacing: 0.5 },
356
+ poweredBy: { textAlign: "center", fontSize: 11, paddingVertical: 8 },
357
+ listPicker: { position: "absolute", inset: 0, top: 0, left: 0, right: 0, bottom: 0, zIndex: 20 },
358
+ listPickerHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: StyleSheet.hairlineWidth },
359
+ listPickerTitle: { fontSize: 14, fontWeight: "600" },
360
+ listPickerSectionTitle: { fontSize: 11, fontWeight: "600", textTransform: "uppercase", letterSpacing: 0.5, paddingHorizontal: 16, paddingTop: 12, paddingBottom: 4 },
361
+ listPickerRow: { paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: StyleSheet.hairlineWidth },
362
+ listPickerRowTitle: { fontSize: 14, fontWeight: "500" },
363
+ listPickerRowDesc: { fontSize: 12, marginTop: 2 },
364
+ });