turbodesk-livechat-react-native 0.1.0-alpha.0

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 (171) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +91 -0
  3. package/dist/api/conversation-api.d.ts +16 -0
  4. package/dist/api/conversation-api.d.ts.map +1 -0
  5. package/dist/api/conversation-api.js +44 -0
  6. package/dist/api/conversation-api.js.map +1 -0
  7. package/dist/api/file-api.d.ts +5 -0
  8. package/dist/api/file-api.d.ts.map +1 -0
  9. package/dist/api/file-api.js +15 -0
  10. package/dist/api/file-api.js.map +1 -0
  11. package/dist/api/widget-api.d.ts +4 -0
  12. package/dist/api/widget-api.d.ts.map +1 -0
  13. package/dist/api/widget-api.js +15 -0
  14. package/dist/api/widget-api.js.map +1 -0
  15. package/dist/axios/axios.d.ts +32 -0
  16. package/dist/axios/axios.d.ts.map +1 -0
  17. package/dist/axios/axios.js +120 -0
  18. package/dist/axios/axios.js.map +1 -0
  19. package/dist/core/config.d.ts +17 -0
  20. package/dist/core/config.d.ts.map +1 -0
  21. package/dist/core/config.js +42 -0
  22. package/dist/core/config.js.map +1 -0
  23. package/dist/core/http-client.d.ts +33 -0
  24. package/dist/core/http-client.d.ts.map +1 -0
  25. package/dist/core/http-client.js +104 -0
  26. package/dist/core/http-client.js.map +1 -0
  27. package/dist/core/identity.d.ts +7 -0
  28. package/dist/core/identity.d.ts.map +1 -0
  29. package/dist/core/identity.js +62 -0
  30. package/dist/core/identity.js.map +1 -0
  31. package/dist/core/visitor-params.d.ts +15 -0
  32. package/dist/core/visitor-params.d.ts.map +1 -0
  33. package/dist/core/visitor-params.js +45 -0
  34. package/dist/core/visitor-params.js.map +1 -0
  35. package/dist/hooks/use-conversations.d.ts +12 -0
  36. package/dist/hooks/use-conversations.d.ts.map +1 -0
  37. package/dist/hooks/use-conversations.js +177 -0
  38. package/dist/hooks/use-conversations.js.map +1 -0
  39. package/dist/hooks/use-live-chat.d.ts +30 -0
  40. package/dist/hooks/use-live-chat.d.ts.map +1 -0
  41. package/dist/hooks/use-live-chat.js +52 -0
  42. package/dist/hooks/use-live-chat.js.map +1 -0
  43. package/dist/hooks/use-messages.d.ts +11 -0
  44. package/dist/hooks/use-messages.d.ts.map +1 -0
  45. package/dist/hooks/use-messages.js +185 -0
  46. package/dist/hooks/use-messages.js.map +1 -0
  47. package/dist/hooks/use-send-message.d.ts +22 -0
  48. package/dist/hooks/use-send-message.d.ts.map +1 -0
  49. package/dist/hooks/use-send-message.js +125 -0
  50. package/dist/hooks/use-send-message.js.map +1 -0
  51. package/dist/index.d.ts +49 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +97 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/navigation/LiveChatPanel.d.ts +5 -0
  56. package/dist/navigation/LiveChatPanel.d.ts.map +1 -0
  57. package/dist/navigation/LiveChatPanel.js +81 -0
  58. package/dist/navigation/LiveChatPanel.js.map +1 -0
  59. package/dist/navigation/panel-router-context.d.ts +22 -0
  60. package/dist/navigation/panel-router-context.d.ts.map +1 -0
  61. package/dist/navigation/panel-router-context.js +42 -0
  62. package/dist/navigation/panel-router-context.js.map +1 -0
  63. package/dist/navigation/router-types.d.ts +2 -0
  64. package/dist/navigation/router-types.d.ts.map +1 -0
  65. package/dist/navigation/router-types.js +3 -0
  66. package/dist/navigation/router-types.js.map +1 -0
  67. package/dist/provider/LiveChatContext.d.ts +4 -0
  68. package/dist/provider/LiveChatContext.d.ts.map +1 -0
  69. package/dist/provider/LiveChatContext.js +35 -0
  70. package/dist/provider/LiveChatContext.js.map +1 -0
  71. package/dist/provider/LiveChatProvider.d.ts +3 -0
  72. package/dist/provider/LiveChatProvider.d.ts.map +1 -0
  73. package/dist/provider/LiveChatProvider.js +308 -0
  74. package/dist/provider/LiveChatProvider.js.map +1 -0
  75. package/dist/provider/types.d.ts +42 -0
  76. package/dist/provider/types.d.ts.map +1 -0
  77. package/dist/provider/types.js +3 -0
  78. package/dist/provider/types.js.map +1 -0
  79. package/dist/realtime/ws-client.d.ts +51 -0
  80. package/dist/realtime/ws-client.d.ts.map +1 -0
  81. package/dist/realtime/ws-client.js +322 -0
  82. package/dist/realtime/ws-client.js.map +1 -0
  83. package/dist/ui/components/AssigneeAvatar.d.ts +12 -0
  84. package/dist/ui/components/AssigneeAvatar.d.ts.map +1 -0
  85. package/dist/ui/components/AssigneeAvatar.js +58 -0
  86. package/dist/ui/components/AssigneeAvatar.js.map +1 -0
  87. package/dist/ui/components/Avatar.d.ts +10 -0
  88. package/dist/ui/components/Avatar.d.ts.map +1 -0
  89. package/dist/ui/components/Avatar.js +76 -0
  90. package/dist/ui/components/Avatar.js.map +1 -0
  91. package/dist/ui/components/ConversationHeader.d.ts +10 -0
  92. package/dist/ui/components/ConversationHeader.d.ts.map +1 -0
  93. package/dist/ui/components/ConversationHeader.js +90 -0
  94. package/dist/ui/components/ConversationHeader.js.map +1 -0
  95. package/dist/ui/components/ConversationListScreen.d.ts +9 -0
  96. package/dist/ui/components/ConversationListScreen.d.ts.map +1 -0
  97. package/dist/ui/components/ConversationListScreen.js +350 -0
  98. package/dist/ui/components/ConversationListScreen.js.map +1 -0
  99. package/dist/ui/components/ConversationScreen.d.ts +8 -0
  100. package/dist/ui/components/ConversationScreen.d.ts.map +1 -0
  101. package/dist/ui/components/ConversationScreen.js +235 -0
  102. package/dist/ui/components/ConversationScreen.js.map +1 -0
  103. package/dist/ui/components/HomeScreen.d.ts +6 -0
  104. package/dist/ui/components/HomeScreen.d.ts.map +1 -0
  105. package/dist/ui/components/HomeScreen.js +133 -0
  106. package/dist/ui/components/HomeScreen.js.map +1 -0
  107. package/dist/ui/components/LivechatMessageRenderer.d.ts +17 -0
  108. package/dist/ui/components/LivechatMessageRenderer.d.ts.map +1 -0
  109. package/dist/ui/components/LivechatMessageRenderer.js +122 -0
  110. package/dist/ui/components/LivechatMessageRenderer.js.map +1 -0
  111. package/dist/ui/components/LogMessage.d.ts +5 -0
  112. package/dist/ui/components/LogMessage.d.ts.map +1 -0
  113. package/dist/ui/components/LogMessage.js +83 -0
  114. package/dist/ui/components/LogMessage.js.map +1 -0
  115. package/dist/ui/components/MessageBubble.d.ts +15 -0
  116. package/dist/ui/components/MessageBubble.d.ts.map +1 -0
  117. package/dist/ui/components/MessageBubble.js +84 -0
  118. package/dist/ui/components/MessageBubble.js.map +1 -0
  119. package/dist/ui/components/MessageComposer.d.ts +31 -0
  120. package/dist/ui/components/MessageComposer.d.ts.map +1 -0
  121. package/dist/ui/components/MessageComposer.js +295 -0
  122. package/dist/ui/components/MessageComposer.js.map +1 -0
  123. package/dist/ui/components/WsStatusStrip.d.ts +2 -0
  124. package/dist/ui/components/WsStatusStrip.d.ts.map +1 -0
  125. package/dist/ui/components/WsStatusStrip.js +103 -0
  126. package/dist/ui/components/WsStatusStrip.js.map +1 -0
  127. package/dist/ui/icons.d.ts +22 -0
  128. package/dist/ui/icons.d.ts.map +1 -0
  129. package/dist/ui/icons.js +71 -0
  130. package/dist/ui/icons.js.map +1 -0
  131. package/dist/ui/theme.d.ts +72 -0
  132. package/dist/ui/theme.d.ts.map +1 -0
  133. package/dist/ui/theme.js +170 -0
  134. package/dist/ui/theme.js.map +1 -0
  135. package/docs/backend-contract.md +392 -0
  136. package/docs/migration-notes.md +32 -0
  137. package/package.json +60 -0
  138. package/src/api/conversation-api.ts +71 -0
  139. package/src/api/file-api.ts +14 -0
  140. package/src/api/widget-api.ts +12 -0
  141. package/src/axios/axios.ts +159 -0
  142. package/src/core/config.ts +54 -0
  143. package/src/core/http-client.ts +136 -0
  144. package/src/core/identity.ts +68 -0
  145. package/src/core/visitor-params.ts +48 -0
  146. package/src/hooks/use-conversations.ts +181 -0
  147. package/src/hooks/use-live-chat.ts +84 -0
  148. package/src/hooks/use-messages.ts +188 -0
  149. package/src/hooks/use-send-message.ts +159 -0
  150. package/src/index.ts +114 -0
  151. package/src/navigation/LiveChatPanel.tsx +118 -0
  152. package/src/navigation/panel-router-context.tsx +89 -0
  153. package/src/navigation/router-types.ts +1 -0
  154. package/src/provider/LiveChatContext.ts +33 -0
  155. package/src/provider/LiveChatProvider.tsx +380 -0
  156. package/src/provider/types.ts +57 -0
  157. package/src/realtime/ws-client.ts +369 -0
  158. package/src/types/react-native-svg.d.ts +10 -0
  159. package/src/ui/components/AssigneeAvatar.tsx +102 -0
  160. package/src/ui/components/Avatar.tsx +110 -0
  161. package/src/ui/components/ConversationHeader.tsx +202 -0
  162. package/src/ui/components/ConversationListScreen.tsx +454 -0
  163. package/src/ui/components/ConversationScreen.tsx +362 -0
  164. package/src/ui/components/HomeScreen.tsx +278 -0
  165. package/src/ui/components/LivechatMessageRenderer.tsx +268 -0
  166. package/src/ui/components/LogMessage.tsx +88 -0
  167. package/src/ui/components/MessageBubble.tsx +148 -0
  168. package/src/ui/components/MessageComposer.tsx +461 -0
  169. package/src/ui/components/WsStatusStrip.tsx +123 -0
  170. package/src/ui/icons.tsx +111 -0
  171. package/src/ui/theme.ts +237 -0
@@ -0,0 +1,362 @@
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
+ });
@@ -0,0 +1,278 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import {
3
+ ActivityIndicator,
4
+ Image,
5
+ StyleSheet,
6
+ Text,
7
+ TouchableOpacity,
8
+ View,
9
+ type ViewStyle,
10
+ } from "react-native";
11
+ import { useLiveChatContext } from "../../provider/LiveChatContext";
12
+ import { conversationApi } from "../../api/conversation-api";
13
+ import { Avatar } from "./Avatar";
14
+ import {
15
+ POWERED_BY_TEXT,
16
+ contrastingTextOnBrand,
17
+ getBrandColor,
18
+ getBrandLogoUrl,
19
+ getNotificationIconColor,
20
+ getWidgetDescription,
21
+ getWidgetTitle,
22
+ getWidgetName,
23
+ } from "../theme";
24
+ import { HistoryChatsIcon, SendPlaneIcon } from "../icons";
25
+
26
+ let LinearGradient: any = null;
27
+ try {
28
+ LinearGradient = require("react-native-linear-gradient").default;
29
+ } catch {
30
+ LinearGradient = null;
31
+ }
32
+
33
+ function GradientBackground({
34
+ brandColor,
35
+ darkColor,
36
+ style,
37
+ children,
38
+ }: {
39
+ brandColor: string;
40
+ darkColor: string;
41
+ style?: ViewStyle;
42
+ children?: React.ReactNode;
43
+ }) {
44
+ if (LinearGradient) {
45
+ return (
46
+ <LinearGradient
47
+ colors={[brandColor, brandColor, darkColor]}
48
+ locations={[0, 0.3, 1]}
49
+ start={{ x: 0.5, y: 0 }}
50
+ end={{ x: 0.5, y: 1 }}
51
+ style={style}
52
+ >
53
+ {children}
54
+ </LinearGradient>
55
+ );
56
+ }
57
+ return (
58
+ <View style={[style, { backgroundColor: brandColor }]}>
59
+ <View
60
+ pointerEvents="none"
61
+ style={{
62
+ position: "absolute",
63
+ left: 0,
64
+ right: 0,
65
+ top: "30%",
66
+ bottom: 0,
67
+ backgroundColor: darkColor,
68
+ }}
69
+ />
70
+ {children}
71
+ </View>
72
+ );
73
+ }
74
+
75
+ export type HomeScreenProps = {
76
+ onStartConversation: () => void;
77
+ onOpenPreviousChats: () => void;
78
+ };
79
+
80
+ function pickCount(raw: any): number {
81
+ const body = raw?.data ?? raw;
82
+ const inner = body?.data ?? body;
83
+ const v = inner?.conversationCount ?? inner?.count ?? inner?.total ?? 0;
84
+ return typeof v === "number" ? v : parseInt(v, 10) || 0;
85
+ }
86
+
87
+ export function HomeScreen({
88
+ onStartConversation,
89
+ onOpenPreviousChats,
90
+ }: HomeScreenProps) {
91
+ const {
92
+ theme: t,
93
+ widgetConfig,
94
+ totalUnread,
95
+ visitorQueryParams,
96
+ embedLoadState,
97
+ } = useLiveChatContext();
98
+
99
+ const [conversationCount, setConversationCount] = useState(0);
100
+
101
+ const settings = widgetConfig?.widgetSettings ?? {};
102
+ const title = getWidgetTitle(widgetConfig);
103
+ const widgetName = getWidgetName(widgetConfig);
104
+ const description = getWidgetDescription(settings);
105
+ const logoUrl = getBrandLogoUrl(settings);
106
+ const brandColor = getBrandColor(settings) || t.colors.brand;
107
+ const contrastText = contrastingTextOnBrand(brandColor);
108
+ const notificationColor = getNotificationIconColor(settings);
109
+ const showUnreadDot = totalUnread > 0;
110
+
111
+ useEffect(() => {
112
+ if (!visitorQueryParams) return;
113
+ let cancelled = false;
114
+ conversationApi
115
+ .getConversationCount(visitorQueryParams)
116
+ .then((raw) => {
117
+ if (!cancelled) setConversationCount(pickCount(raw));
118
+ })
119
+ .catch(() => {});
120
+ return () => {
121
+ cancelled = true;
122
+ };
123
+ }, [visitorQueryParams]);
124
+
125
+ // Dark color anchoring the bottom of the gradient — matches web widget
126
+ const darkColor = t.appearance === "dark" ? "#09090b" : "#111827";
127
+ const isLoading = embedLoadState === "loading";
128
+
129
+ return (
130
+ <GradientBackground
131
+ brandColor={brandColor}
132
+ darkColor={darkColor}
133
+ style={styles.flex}
134
+ >
135
+ {/* top — branding */}
136
+ <View style={styles.header}>
137
+ {logoUrl ? (
138
+ <Image
139
+ source={{ uri: logoUrl }}
140
+ style={styles.brandLogo}
141
+ resizeMode="cover"
142
+ />
143
+ ) : (
144
+ <Avatar name={widgetName} size={44} />
145
+ )}
146
+ <Text style={[styles.headerTitle, { color: contrastText }]} numberOfLines={2}>
147
+ {title}
148
+ </Text>
149
+ <Text
150
+ style={[styles.headerSubtitle, { color: contrastText, opacity: 0.82 }]}
151
+ numberOfLines={3}
152
+ >
153
+ {description}
154
+ </Text>
155
+ </View>
156
+
157
+ {/* bottom — actions */}
158
+ <View style={styles.bottom}>
159
+ <View style={styles.actions}>
160
+ {conversationCount > 0 ? (
161
+ <TouchableOpacity
162
+ onPress={onOpenPreviousChats}
163
+ activeOpacity={0.7}
164
+ style={[
165
+ styles.prevRow,
166
+ {
167
+ backgroundColor: "rgba(0,0,0,0.35)",
168
+ borderColor: "rgba(255,255,255,0.1)",
169
+ },
170
+ ]}
171
+ accessibilityLabel={`Previous chats${
172
+ showUnreadDot ? ", unread messages" : ""
173
+ }${
174
+ conversationCount > 1
175
+ ? `, ${conversationCount} conversations`
176
+ : ""
177
+ }`}
178
+ >
179
+ <View style={styles.prevRowLeft}>
180
+ <HistoryChatsIcon size={20} color="#ffffff" />
181
+ <Text style={[styles.prevRowLabel, { color: "#ffffff" }]}>
182
+ Previous chats
183
+ {conversationCount > 1 ? (
184
+ <Text
185
+ style={{ fontWeight: "400", color: "rgba(255,255,255,0.7)" }}
186
+ >
187
+ {" "}
188
+ ({conversationCount})
189
+ </Text>
190
+ ) : null}
191
+ </Text>
192
+ </View>
193
+ {showUnreadDot ? (
194
+ <View
195
+ style={[
196
+ styles.unreadDot,
197
+ { backgroundColor: notificationColor },
198
+ ]}
199
+ />
200
+ ) : null}
201
+ </TouchableOpacity>
202
+ ) : null}
203
+
204
+ {isLoading ? (
205
+ <ActivityIndicator color="#ffffff" style={{ marginVertical: 8 }} />
206
+ ) : null}
207
+
208
+ <TouchableOpacity
209
+ onPress={onStartConversation}
210
+ activeOpacity={0.85}
211
+ style={[styles.startBtn, { backgroundColor: "#ffffff" }]}
212
+ accessibilityLabel="Send us a message"
213
+ >
214
+ <Text style={[styles.startBtnText, { color: "#111827" }]}>
215
+ Send us a message
216
+ </Text>
217
+ <SendPlaneIcon size={18} color={brandColor} />
218
+ </TouchableOpacity>
219
+ </View>
220
+ </View>
221
+
222
+ <Text style={[styles.poweredBy, { color: "rgba(255,255,255,0.7)" }]}>
223
+ {POWERED_BY_TEXT}
224
+ </Text>
225
+ </GradientBackground>
226
+ );
227
+ }
228
+
229
+ const styles = StyleSheet.create({
230
+ flex: { flex: 1 },
231
+ header: {
232
+ flex: 0.3,
233
+ paddingHorizontal: 20,
234
+ paddingTop: 52,
235
+ paddingBottom: 32,
236
+ gap: 12,
237
+ justifyContent: "flex-start",
238
+ },
239
+ brandLogo: { width: 44, height: 44, borderRadius: 22 },
240
+ headerTitle: {
241
+ fontSize: 28,
242
+ fontWeight: "700",
243
+ lineHeight: 34,
244
+ marginTop: 16,
245
+ },
246
+ headerSubtitle: {
247
+ fontSize: 15,
248
+ lineHeight: 22,
249
+ marginTop: 8,
250
+ },
251
+ bottom: {
252
+ flex: 0.7,
253
+ justifyContent: "flex-end",
254
+ },
255
+ actions: { paddingHorizontal: 16, gap: 12, paddingBottom: 8 },
256
+ prevRow: {
257
+ flexDirection: "row",
258
+ alignItems: "center",
259
+ justifyContent: "space-between",
260
+ paddingHorizontal: 16,
261
+ paddingVertical: 14,
262
+ borderRadius: 12,
263
+ borderWidth: 1,
264
+ },
265
+ prevRowLeft: { flexDirection: "row", alignItems: "center", gap: 12, flex: 1 },
266
+ prevRowLabel: { fontSize: 15, fontWeight: "600" },
267
+ unreadDot: { width: 10, height: 10, borderRadius: 5 },
268
+ startBtn: {
269
+ flexDirection: "row",
270
+ alignItems: "center",
271
+ justifyContent: "space-between",
272
+ paddingHorizontal: 20,
273
+ paddingVertical: 14,
274
+ borderRadius: 12,
275
+ },
276
+ startBtnText: { fontSize: 15, fontWeight: "600" },
277
+ poweredBy: { textAlign: "center", fontSize: 11, paddingVertical: 8 },
278
+ });