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,202 @@
1
+ import React from "react";
2
+ import {
3
+ Platform,
4
+ StatusBar,
5
+ StyleSheet,
6
+ Text,
7
+ TouchableOpacity,
8
+ View,
9
+ } from "react-native";
10
+ import { useLiveChatContext } from "../../provider/LiveChatContext";
11
+ import { getUserPresenceStatus } from "../../core/visitor-params";
12
+ import { AssigneeAvatar } from "./AssigneeAvatar";
13
+ import { ChevronLeftIcon, CloseIcon } from "../icons";
14
+
15
+ export type ConversationHeaderProps = {
16
+ /** Loaded conversation from API; `null` = new chat or still loading. */
17
+ conversation: any | null;
18
+ /** True while the conversation is being fetched (id known, data pending). */
19
+ isLoading?: boolean;
20
+ onBack?: () => void;
21
+ onClose?: () => void;
22
+ };
23
+
24
+ function SkeletonBar({ width, height }: { width: number; height: number }) {
25
+ const { theme: t } = useLiveChatContext();
26
+ return (
27
+ <View
28
+ style={{
29
+ width,
30
+ height,
31
+ borderRadius: height / 2,
32
+ backgroundColor: t.colors.border,
33
+ opacity: 0.4,
34
+ }}
35
+ />
36
+ );
37
+ }
38
+
39
+ export function ConversationHeader({
40
+ conversation,
41
+ isLoading = false,
42
+ onBack,
43
+ onClose,
44
+ }: ConversationHeaderProps) {
45
+ const { theme: t, widgetName, widgetConfig } = useLiveChatContext();
46
+ const headerBg = t.colors.headerBackground;
47
+ const headerTxt = t.colors.headerText;
48
+
49
+ const settings = widgetConfig?.widgetSettings ?? {};
50
+ const brandLogoUrl = settings?.brandLogoUrl ?? null;
51
+
52
+ const conv = conversation?.conversation ?? conversation;
53
+ const assigneeType = conv?.assigneeType ?? null;
54
+ const userBlock = assigneeType === "user" ? conv?.userId : null;
55
+ const botBlock = assigneeType === "bot" ? conv?.botId : null;
56
+
57
+ const title = !conv
58
+ ? widgetName
59
+ : assigneeType === "ai"
60
+ ? conv?.aiName ?? "AI Assistant"
61
+ : assigneeType === "user"
62
+ ? conv?.assigneeName ?? userBlock?.name ?? widgetName
63
+ : assigneeType === "bot"
64
+ ? conv?.botName ?? botBlock?.name ?? widgetName
65
+ : widgetName;
66
+
67
+ const userPresence = getUserPresenceStatus(userBlock);
68
+ const presenceStatus: "active" | "away" | null =
69
+ assigneeType === "user" && userPresence ? userPresence : null;
70
+
71
+ const presenceLabel =
72
+ presenceStatus === "active"
73
+ ? "Active"
74
+ : presenceStatus === "away"
75
+ ? "Away"
76
+ : null;
77
+
78
+ const roleSubtitle =
79
+ conv && assigneeType === "user"
80
+ ? conv?.assigneeRole ?? userBlock?.role ?? null
81
+ : null;
82
+
83
+ const subtitle = presenceLabel ?? roleSubtitle ?? (conv?.status === "resolved" ? "Resolved" : null);
84
+
85
+ const avatarUrl =
86
+ conv?.assigneeAvatarUrl ??
87
+ userBlock?.avatarUrl ??
88
+ botBlock?.avatarUrl ??
89
+ null;
90
+
91
+ return (
92
+ <View
93
+ style={[
94
+ styles.container,
95
+ { backgroundColor: headerBg, borderBottomColor: t.colors.border },
96
+ ]}
97
+ >
98
+ {onBack ? (
99
+ <TouchableOpacity
100
+ onPress={onBack}
101
+ style={styles.iconBtn}
102
+ accessibilityLabel="Go back"
103
+ >
104
+ <ChevronLeftIcon size={22} color={headerTxt} />
105
+ </TouchableOpacity>
106
+ ) : (
107
+ <View style={styles.iconBtn} />
108
+ )}
109
+
110
+ <View style={styles.center}>
111
+ {isLoading ? (
112
+ <View style={[styles.skeletonAvatar, { backgroundColor: t.colors.border }]} />
113
+ ) : (
114
+ <AssigneeAvatar
115
+ assigneeType={assigneeType}
116
+ name={title}
117
+ avatarUrl={avatarUrl}
118
+ brandLogoUrl={brandLogoUrl}
119
+ size={36}
120
+ showPresenceDot={assigneeType === "user"}
121
+ presenceStatus={presenceStatus}
122
+ />
123
+ )}
124
+
125
+ <View style={styles.titleBlock}>
126
+ {isLoading ? (
127
+ <>
128
+ <SkeletonBar width={120} height={12} />
129
+ <View style={{ marginTop: 5 }}>
130
+ <SkeletonBar width={80} height={9} />
131
+ </View>
132
+ </>
133
+ ) : (
134
+ <>
135
+ <Text
136
+ style={[styles.title, { color: headerTxt, fontSize: t.fontSizes.md }]}
137
+ numberOfLines={1}
138
+ >
139
+ {title}
140
+ </Text>
141
+ {subtitle ? (
142
+ <Text
143
+ style={[
144
+ styles.subtitle,
145
+ {
146
+ color:
147
+ presenceStatus === "active"
148
+ ? t.colors.presenceActive
149
+ : t.colors.textMuted,
150
+ fontSize: t.fontSizes.xs,
151
+ },
152
+ ]}
153
+ numberOfLines={1}
154
+ >
155
+ {subtitle}
156
+ </Text>
157
+ ) : null}
158
+ </>
159
+ )}
160
+ </View>
161
+ </View>
162
+
163
+ {onClose ? (
164
+ <TouchableOpacity
165
+ onPress={onClose}
166
+ style={styles.iconBtn}
167
+ accessibilityLabel="Close chat"
168
+ >
169
+ <CloseIcon size={18} color={headerTxt} />
170
+ </TouchableOpacity>
171
+ ) : (
172
+ <View style={styles.iconBtn} />
173
+ )}
174
+ </View>
175
+ );
176
+ }
177
+
178
+ const PT = Platform.OS === "ios" ? (StatusBar.currentHeight ?? 0) : 0;
179
+
180
+ const styles = StyleSheet.create({
181
+ container: {
182
+ flexDirection: "row",
183
+ alignItems: "center",
184
+ paddingTop: PT + 10,
185
+ paddingBottom: 10,
186
+ paddingHorizontal: 8,
187
+ borderBottomWidth: StyleSheet.hairlineWidth,
188
+ },
189
+ iconBtn: { width: 44, alignItems: "center", justifyContent: "center" },
190
+ center: {
191
+ flex: 1,
192
+ flexDirection: "row",
193
+ alignItems: "center",
194
+ paddingHorizontal: 4,
195
+ },
196
+ titleBlock: { flex: 1, marginLeft: 8 },
197
+ title: { fontWeight: "600" },
198
+ subtitle: { marginTop: 2 },
199
+ backArrow: { fontSize: 22, fontWeight: "500" },
200
+ closeIcon: { fontSize: 26, lineHeight: 28 },
201
+ skeletonAvatar: { width: 36, height: 36, borderRadius: 10, opacity: 0.2 },
202
+ });
@@ -0,0 +1,454 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from "react";
2
+ import {
3
+ ActivityIndicator,
4
+ FlatList,
5
+ StyleSheet,
6
+ Text,
7
+ TouchableOpacity,
8
+ View,
9
+ } from "react-native";
10
+ import { useLiveChatContext } from "../../provider/LiveChatContext";
11
+ import { conversationApi } from "../../api/conversation-api";
12
+ import { AssigneeAvatar } from "./AssigneeAvatar";
13
+ import type { AssigneeType } from "./AssigneeAvatar";
14
+ import { SendPlaneIcon, ChevronLeftIcon, CloseIcon } from "../icons";
15
+ import { POWERED_BY_TEXT, getBrandColor, contrastingTextOnBrand } from "../theme";
16
+
17
+ // ── format date — mirrors format-list-message-date.ts ────────────────────────
18
+
19
+ export function formatListMessageDate(
20
+ input: string | number | Date | null | undefined
21
+ ): string {
22
+ if (input == null || input === "") return "";
23
+ const t = typeof input === "number" ? input : new Date(input).getTime();
24
+ if (!Number.isFinite(t)) return "";
25
+ const now = Date.now();
26
+ const dSec = (now - t) / 1000;
27
+ if (dSec < 0) return "now";
28
+ if (dSec < 60) return `${Math.max(0, Math.floor(dSec))}s`;
29
+ if (dSec < 3600) return `${Math.floor(dSec / 60)}m`;
30
+ if (dSec < 86400) return `${Math.floor(dSec / 3600)}h`;
31
+ if (dSec < 86400 * 7) return `${Math.floor(dSec / 86400)}d`;
32
+ if (dSec < 86400 * 30) return `${Math.floor(dSec / (86400 * 7))}w`;
33
+ try {
34
+ return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(new Date(t));
35
+ } catch {
36
+ return "";
37
+ }
38
+ }
39
+
40
+ // ── message preview — mirrors chat-list-card-message-preview.tsx ─────────────
41
+
42
+ function MessagePreview({ message }: { message: any }) {
43
+ const { theme: t } = useLiveChatContext();
44
+ if (message?.channelType === "livechat" && message?.livechat) {
45
+ const lt = message.livechat;
46
+ if (lt.type === "text") return <Text style={{ color: t.colors.textMuted }} numberOfLines={1}>{lt?.text?.body ?? ""}</Text>;
47
+ if (lt.type === "image") return <Text style={{ color: t.colors.textMuted }}>📷 Image</Text>;
48
+ if (lt.type === "video") return <Text style={{ color: t.colors.textMuted }}>▶ Video</Text>;
49
+ if (lt.type === "audio") return <Text style={{ color: t.colors.textMuted }}>🎵 Audio</Text>;
50
+ if (lt.type === "document") return <Text style={{ color: t.colors.textMuted }} numberOfLines={1}>📄 {message?.fileName || lt?.document?.filename || "Document"}</Text>;
51
+ if (lt.type === "interactive") {
52
+ const body = lt.interactive?.body?.text ?? lt.interactive?.button_reply?.title ?? lt.interactive?.list_reply?.title ?? null;
53
+ return <Text style={{ color: t.colors.textMuted }} numberOfLines={1}>{body ?? "[Interactive]"}</Text>;
54
+ }
55
+ }
56
+ if (message?.text?.body) return <Text style={{ color: t.colors.textMuted }} numberOfLines={1}>{message.text.body}</Text>;
57
+ return <Text style={{ color: t.colors.textMuted, opacity: 0.7 }}>Message</Text>;
58
+ }
59
+
60
+ // ── conversation card — mirrors chat-list-card.tsx ────────────────────────────
61
+
62
+ function resolveListTitle(conversation: any, widgetName: string): string {
63
+ const type = conversation?.assigneeType;
64
+ const userBlock = type === "user" ? conversation?.userId : null;
65
+ const botBlock = type === "bot" ? conversation?.botId : null;
66
+ if (type === "ai") return conversation?.aiName ?? "AI Assistant";
67
+ if (type === "user") return conversation?.assigneeName ?? userBlock?.name ?? widgetName;
68
+ if (type === "bot") return conversation?.botName ?? botBlock?.name ?? widgetName;
69
+ return widgetName;
70
+ }
71
+
72
+ function ConversationCard({
73
+ item,
74
+ onOpen,
75
+ onMarkRead,
76
+ }: {
77
+ item: any;
78
+ onOpen: (conv: any) => void;
79
+ onMarkRead: (conv: any) => void;
80
+ }) {
81
+ const { theme: t, widgetName, widgetConfig } = useLiveChatContext();
82
+ const brandLogoUrl = widgetConfig?.widgetSettings?.brandLogoUrl ?? null;
83
+
84
+ const assigneeType: AssigneeType = item?.assigneeType ?? null;
85
+ const userBlock = assigneeType === "user" ? item?.userId : null;
86
+ const botBlock = assigneeType === "bot" ? item?.botId : null;
87
+ const avatarUrl = item?.assigneeAvatarUrl ?? userBlock?.avatarUrl ?? botBlock?.avatarUrl ?? null;
88
+ const presenceStatus: "active" | "away" | null =
89
+ userBlock?.presence?.status === "active" ? "active"
90
+ : userBlock?.presence?.status === "away" ? "away" : null;
91
+
92
+ const title = resolveListTitle(item, widgetName);
93
+ const hasUnread = !!(item?.livechat?.visitorUnreadCount);
94
+ const previewMessage = item?.latestMessageId;
95
+ const timeLabel = formatListMessageDate(previewMessage?.createdAt ?? item?.updatedAt);
96
+ const senderPrefix = previewMessage?.senderType === "contact" ? "You: " : null;
97
+
98
+ return (
99
+ <TouchableOpacity
100
+ onPress={() => { onMarkRead(item); onOpen(item); }}
101
+ activeOpacity={0.7}
102
+ style={[styles.card, { borderBottomColor: t.colors.border, backgroundColor: t.colors.surface }]}
103
+ >
104
+ <AssigneeAvatar
105
+ assigneeType={assigneeType}
106
+ name={title}
107
+ avatarUrl={avatarUrl}
108
+ brandLogoUrl={brandLogoUrl}
109
+ size={44}
110
+ showPresenceDot={assigneeType === "user"}
111
+ presenceStatus={presenceStatus}
112
+ />
113
+ <View style={styles.cardContent}>
114
+ <View style={styles.cardTopRow}>
115
+ <Text
116
+ style={[styles.cardTitle, { color: t.colors.text, fontSize: t.fontSizes.md, fontWeight: hasUnread ? "700" : "600" }]}
117
+ numberOfLines={1}
118
+ >
119
+ {title}
120
+ </Text>
121
+ {timeLabel ? (
122
+ <Text style={[styles.cardDate, { color: t.colors.textMuted, fontSize: t.fontSizes.xs }]}>
123
+ {timeLabel}
124
+ </Text>
125
+ ) : null}
126
+ </View>
127
+ <View style={styles.cardBottomRow}>
128
+ <View style={styles.cardPreviewRow}>
129
+ {senderPrefix ? (
130
+ <Text style={{ color: t.colors.textMuted, fontSize: t.fontSizes.sm, fontStyle: "italic" }}>{senderPrefix}</Text>
131
+ ) : null}
132
+ {previewMessage ? (
133
+ <View style={styles.cardPreviewFlex}>
134
+ <MessagePreview message={previewMessage} />
135
+ </View>
136
+ ) : (
137
+ <Text style={{ color: t.colors.textMuted, fontSize: t.fontSizes.sm }}>
138
+ {item?.status === "resolved" ? "Conversation closed" : "No messages yet"}
139
+ </Text>
140
+ )}
141
+ </View>
142
+ {hasUnread ? <View style={[styles.unreadDot, { backgroundColor: t.colors.unreadBadge }]} /> : null}
143
+ </View>
144
+ </View>
145
+ </TouchableOpacity>
146
+ );
147
+ }
148
+
149
+ // ── screen ────────────────────────────────────────────────────────────────────
150
+
151
+ const PAGE_SIZE = 20;
152
+
153
+ export type ConversationListScreenProps = {
154
+ onSelectConversation: (conversationId: string, conversation: any) => void;
155
+ onNewConversation?: () => void;
156
+ onBack?: () => void;
157
+ onClose?: () => void;
158
+ };
159
+
160
+ export function ConversationListScreen({
161
+ onSelectConversation,
162
+ onNewConversation,
163
+ onBack,
164
+ onClose,
165
+ }: ConversationListScreenProps) {
166
+ const { theme: t, visitorQueryParams, wsClient, setTotalUnread, widgetConfig } = useLiveChatContext();
167
+ const settings = widgetConfig?.widgetSettings ?? {};
168
+ const brandColor = getBrandColor(settings);
169
+ const contrastText = contrastingTextOnBrand(brandColor);
170
+
171
+ const [list, setList] = useState<any[] | null>(null);
172
+ const [loading, setLoading] = useState(true);
173
+ const [loadingMore, setLoadingMore] = useState(false);
174
+ const [error, setError] = useState<string | null>(null);
175
+ const [hasNextPage, setHasNextPage] = useState(false);
176
+ const [retryKey, setRetryKey] = useState(0);
177
+
178
+ const lastPageRef = useRef(0);
179
+ const inFlightRef = useRef(false);
180
+
181
+ // ── initial load ────────────────────────────────────────────────────────
182
+ useEffect(() => {
183
+ if (!visitorQueryParams) {
184
+ setList(null); setLoading(false); setError(null); setHasNextPage(false);
185
+ return;
186
+ }
187
+ const ac = new AbortController();
188
+ setList(null); setLoading(true); setError(null); setHasNextPage(false);
189
+ lastPageRef.current = 0; inFlightRef.current = false;
190
+
191
+ (async () => {
192
+ try {
193
+ const raw = await conversationApi.getConversations({
194
+ params: { ...visitorQueryParams, page: 1, limit: PAGE_SIZE },
195
+ signal: ac.signal,
196
+ });
197
+ if (ac.signal.aborted) return;
198
+ const body = raw?.data ?? raw;
199
+ const rows: any[] = Array.isArray(body) ? body
200
+ : Array.isArray(body?.conversations) ? body.conversations
201
+ : Array.isArray(body?.data) ? body.data : [];
202
+ const pagination = body?.pagination ?? {};
203
+ setList(rows);
204
+ setHasNextPage(!!pagination.hasNextPage);
205
+ lastPageRef.current = 1;
206
+ } catch (e: any) {
207
+ if (ac.signal.aborted || e?.name === "AbortError") return;
208
+ setError("Couldn't load conversations. Try again.");
209
+ setList(null);
210
+ } finally {
211
+ if (!ac.signal.aborted) setLoading(false);
212
+ }
213
+ })();
214
+ return () => ac.abort();
215
+ }, [visitorQueryParams, retryKey]);
216
+
217
+ // ── load more ───────────────────────────────────────────────────────────
218
+ const loadMore = useCallback(async () => {
219
+ if (!visitorQueryParams || !hasNextPage || inFlightRef.current || loading) return;
220
+ const nextPage = lastPageRef.current + 1;
221
+ inFlightRef.current = true;
222
+ setLoadingMore(true);
223
+ try {
224
+ const raw = await conversationApi.getConversations({
225
+ params: { ...visitorQueryParams, page: nextPage, limit: PAGE_SIZE },
226
+ });
227
+ const body = raw?.data ?? raw;
228
+ const rows: any[] = Array.isArray(body) ? body
229
+ : Array.isArray(body?.conversations) ? body.conversations
230
+ : Array.isArray(body?.data) ? body.data : [];
231
+ const pagination = body?.pagination ?? {};
232
+ setList((prev) => {
233
+ if (!prev) return rows;
234
+ const seen = new Set(prev.map((c: any) => String(c?._id ?? "")));
235
+ const merged = [...prev];
236
+ for (const c of rows) {
237
+ const id = String(c?._id ?? "");
238
+ if (!id || seen.has(id)) continue;
239
+ seen.add(id); merged.push(c);
240
+ }
241
+ return merged;
242
+ });
243
+ setHasNextPage(!!pagination.hasNextPage);
244
+ lastPageRef.current = nextPage;
245
+ } catch { /* ignore */ } finally {
246
+ inFlightRef.current = false;
247
+ setLoadingMore(false);
248
+ }
249
+ }, [visitorQueryParams, hasNextPage, loading]);
250
+
251
+ // ── WS handlers — mirrors web exactly ──────────────────────────────────
252
+ useEffect(() => {
253
+ if (!wsClient) return;
254
+
255
+ const handleNewMessage = (payload: any) => {
256
+ const data = payload?.data ?? payload;
257
+ const conv = data?.conversation;
258
+ const id = conv?._id;
259
+ if (!id) return;
260
+ const msg = data?.message;
261
+ setList((prev) => {
262
+ if (!prev) return prev;
263
+ const idx = prev.findIndex((c: any) => c?._id === id);
264
+ const updated = {
265
+ ...(idx > -1 ? prev[idx] : conv),
266
+ latestMessageId: msg,
267
+ latestMessageAt: conv?.latestMessageAt,
268
+ userUnreadCount: conv?.userUnreadCount,
269
+ livechat: { visitorUnreadCount: conv?.livechat?.visitorUnreadCount, ...conv?.livechat },
270
+ hasUnreadMessages: (conv?.livechat?.visitorUnreadCount ?? 0) > 0,
271
+ };
272
+ if (idx > -1) { const next = [...prev]; next[idx] = updated; return next; }
273
+ return [updated, ...prev];
274
+ });
275
+ };
276
+
277
+ const patchConv = (payload: any) => {
278
+ const data = payload?.data ?? payload;
279
+ const incoming = data?.conversation;
280
+ const id = incoming?._id;
281
+ if (!id) return;
282
+ setList((prev) => {
283
+ if (!prev) return prev;
284
+ const idx = prev.findIndex((c: any) => c?._id === id);
285
+ if (idx > -1) { const next = [...prev]; next[idx] = { ...next[idx], ...incoming }; return next; }
286
+ return [incoming, ...prev];
287
+ });
288
+ };
289
+
290
+ const u1 = wsClient.subscribe("new_livechat_message", handleNewMessage);
291
+ const u2 = wsClient.subscribe("conversation_intervened", patchConv);
292
+ const u3 = wsClient.subscribe("conversation_unassigned", patchConv);
293
+ const u4 = wsClient.subscribe("conversation_transferred", patchConv);
294
+ const u5 = wsClient.subscribe("conversation_resolved", patchConv);
295
+ return () => { u1(); u2(); u3(); u4(); u5(); };
296
+ }, [wsClient]);
297
+
298
+ // ── mark read ───────────────────────────────────────────────────────────
299
+ const handleMarkRead = useCallback((conv: any) => {
300
+ if (!visitorQueryParams) return;
301
+ const id = conv?._id;
302
+ if (!id) return;
303
+ // mirrors web: use visitorUnreadCount as the source of truth
304
+ const visitorUnread = Number(conv?.livechat?.visitorUnreadCount ?? 0);
305
+ const prevCount = visitorUnread > 0 ? visitorUnread : 0;
306
+ // optimistic clear — reset visitorUnreadCount so dot disappears immediately
307
+ setList((cur) => cur ? cur.map((c) => c?._id === id ? {
308
+ ...c,
309
+ hasUnreadMessages: false,
310
+ userUnreadCount: 0,
311
+ livechat: { ...c?.livechat, visitorUnreadCount: 0 },
312
+ } : c) : cur);
313
+ if (prevCount > 0) setTotalUnread((n) => Math.max(0, n - prevCount));
314
+ conversationApi.markMessageAsRead(id, { params: visitorQueryParams }).catch(() => {
315
+ // rollback on failure
316
+ setList((cur) => cur ? cur.map((c) => c?._id === id ? {
317
+ ...c,
318
+ hasUnreadMessages: conv.hasUnreadMessages,
319
+ userUnreadCount: conv.userUnreadCount,
320
+ livechat: { ...c?.livechat, visitorUnreadCount: visitorUnread },
321
+ } : c) : cur);
322
+ if (prevCount > 0) setTotalUnread((n) => n + prevCount);
323
+ });
324
+ }, [visitorQueryParams, setTotalUnread]);
325
+
326
+ const handleOpen = useCallback((conv: any) => {
327
+ const id = conv?._id;
328
+ if (id) onSelectConversation(String(id), conv);
329
+ }, [onSelectConversation]);
330
+
331
+ // ── render ──────────────────────────────────────────────────────────────
332
+ return (
333
+ <View style={[styles.flex, { backgroundColor: t.colors.background }]}>
334
+ {/* header */}
335
+ <View style={[styles.header, { backgroundColor: t.colors.headerBackground, borderBottomColor: t.colors.border }]}>
336
+ {onBack ? (
337
+ <TouchableOpacity onPress={onBack} style={styles.headerBtn} accessibilityLabel="Back">
338
+ <ChevronLeftIcon size={22} color={t.colors.headerText} />
339
+ </TouchableOpacity>
340
+ ) : <View style={styles.headerBtn} />}
341
+ <Text style={[styles.headerTitle, { color: t.colors.text }]}>Messages</Text>
342
+ {onClose ? (
343
+ <TouchableOpacity onPress={onClose} style={styles.headerBtn} accessibilityLabel="Close">
344
+ <CloseIcon size={18} color={t.colors.headerText} />
345
+ </TouchableOpacity>
346
+ ) : <View style={styles.headerBtn} />}
347
+ </View>
348
+
349
+ {/* list */}
350
+ {loading ? (
351
+ <View style={styles.center}>
352
+ <ActivityIndicator color={t.colors.brand} size="large" />
353
+ </View>
354
+ ) : error ? (
355
+ <View style={styles.center}>
356
+ <Text style={[styles.errorText, { color: t.colors.error }]}>{error}</Text>
357
+ <TouchableOpacity
358
+ onPress={() => { setError(null); setLoading(true); setRetryKey((k) => k + 1); }}
359
+ style={[styles.retryBtn, { backgroundColor: t.colors.brand }]}
360
+ >
361
+ <Text style={[styles.retryBtnText, { color: "#fff" }]}>Try again</Text>
362
+ </TouchableOpacity>
363
+ </View>
364
+ ) : (
365
+ <FlatList
366
+ data={list ?? []}
367
+ keyExtractor={(item) => String(item?._id ?? Math.random())}
368
+ renderItem={({ item }) => (
369
+ <ConversationCard item={item} onOpen={handleOpen} onMarkRead={handleMarkRead} />
370
+ )}
371
+ ListEmptyComponent={
372
+ <View style={styles.emptyContainer}>
373
+ <Text style={[styles.emptyText, { color: t.colors.textMuted, fontSize: t.fontSizes.md }]}>
374
+ No previous conversations yet.
375
+ </Text>
376
+ </View>
377
+ }
378
+ ListFooterComponent={loadingMore ? <ActivityIndicator color={t.colors.brand} style={{ margin: 16 }} /> : null}
379
+ onEndReached={hasNextPage ? loadMore : undefined}
380
+ onEndReachedThreshold={0.3}
381
+ contentContainerStyle={{ paddingBottom: 80 }}
382
+ />
383
+ )}
384
+
385
+ {/* send new message button — mirrors web's primary CTA */}
386
+ {onNewConversation ? (
387
+ <View style={styles.fabContainer}>
388
+ <TouchableOpacity
389
+ onPress={onNewConversation}
390
+ activeOpacity={0.85}
391
+ style={[styles.newMsgBtn, { backgroundColor: brandColor }]}
392
+ accessibilityLabel="Send us a message"
393
+ >
394
+ <Text style={[styles.newMsgBtnText, { color: contrastText }]}>Send us a message</Text>
395
+ <SendPlaneIcon size={18} color={contrastText} />
396
+ </TouchableOpacity>
397
+ </View>
398
+ ) : null}
399
+
400
+ <Text style={[styles.poweredBy, { color: t.colors.textMuted }]}>{POWERED_BY_TEXT}</Text>
401
+ </View>
402
+ );
403
+ }
404
+
405
+ const styles = StyleSheet.create({
406
+ flex: { flex: 1 },
407
+ center: { flex: 1, alignItems: "center", justifyContent: "center" },
408
+ header: {
409
+ flexDirection: "row",
410
+ alignItems: "center",
411
+ paddingVertical: 10,
412
+ paddingHorizontal: 4,
413
+ borderBottomWidth: StyleSheet.hairlineWidth,
414
+ },
415
+ headerBtn: { width: 44, alignItems: "center", justifyContent: "center" },
416
+ headerBtnText: { fontSize: 22 },
417
+ headerTitle: { flex: 1, textAlign: "center", fontSize: 16, fontWeight: "700" },
418
+ card: {
419
+ flexDirection: "row",
420
+ alignItems: "center",
421
+ paddingHorizontal: 16,
422
+ paddingVertical: 12,
423
+ borderBottomWidth: StyleSheet.hairlineWidth,
424
+ },
425
+ cardContent: { flex: 1, marginLeft: 12 },
426
+ cardTopRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
427
+ cardBottomRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginTop: 3 },
428
+ cardTitle: {},
429
+ cardDate: {},
430
+ cardPreviewRow: { flex: 1, flexDirection: "row", alignItems: "center", marginRight: 8 },
431
+ cardPreviewFlex: { flex: 1 },
432
+ unreadDot: { width: 8, height: 8, borderRadius: 4, flexShrink: 0 },
433
+ emptyContainer: { flex: 1, alignItems: "center", justifyContent: "center", paddingTop: 80 },
434
+ emptyText: { textAlign: "center" },
435
+ errorText: { textAlign: "center", marginBottom: 16 },
436
+ retryBtn: { paddingHorizontal: 24, paddingVertical: 10, borderRadius: 20 },
437
+ retryBtnText: { fontWeight: "600" },
438
+ fabContainer: { position: "absolute", bottom: 28, left: 16, right: 16 },
439
+ newMsgBtn: {
440
+ flexDirection: "row",
441
+ alignItems: "center",
442
+ justifyContent: "space-between",
443
+ paddingHorizontal: 20,
444
+ paddingVertical: 14,
445
+ borderRadius: 14,
446
+ elevation: 4,
447
+ shadowColor: "#000",
448
+ shadowOffset: { width: 0, height: 2 },
449
+ shadowOpacity: 0.18,
450
+ shadowRadius: 6,
451
+ },
452
+ newMsgBtnText: { fontSize: 15, fontWeight: "600" },
453
+ poweredBy: { textAlign: "center", fontSize: 11, paddingVertical: 8 },
454
+ });