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