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,268 @@
1
+ import React from "react";
2
+ import {
3
+ Image,
4
+ Linking,
5
+ StyleSheet,
6
+ Text,
7
+ TouchableOpacity,
8
+ View,
9
+ } from "react-native";
10
+
11
+ export type InteractiveReplyPayload = {
12
+ type: "button_reply" | "list_reply";
13
+ id: string;
14
+ title: string;
15
+ messageId: string;
16
+ };
17
+
18
+ export type ShowListPickerFn = (
19
+ sections: any[],
20
+ onSelect: (id: string, title: string) => void
21
+ ) => void;
22
+
23
+ // ── text ──────────────────────────────────────────────────────────────────────
24
+
25
+ function TextRenderer({ body, textColor, fontSize }: { body: string; textColor: string; fontSize: number }) {
26
+ return (
27
+ <Text style={[styles.text, { color: textColor, fontSize }]}>{body}</Text>
28
+ );
29
+ }
30
+
31
+ // ── image ─────────────────────────────────────────────────────────────────────
32
+
33
+ function ImageRenderer({ url, caption, textColor }: { url: string; caption?: string; textColor: string }) {
34
+ return (
35
+ <TouchableOpacity onPress={() => Linking.openURL(url)} activeOpacity={0.85} style={styles.mediaPad}>
36
+ <Image source={{ uri: url }} style={styles.mediaImage} resizeMode="contain" />
37
+ {caption ? <Text style={[styles.caption, { color: textColor }]}>{caption}</Text> : null}
38
+ </TouchableOpacity>
39
+ );
40
+ }
41
+
42
+ // ── video ─────────────────────────────────────────────────────────────────────
43
+
44
+ function VideoRenderer({ url, caption, textColor }: { url: string; caption?: string; textColor: string }) {
45
+ return (
46
+ <TouchableOpacity onPress={() => Linking.openURL(url)} activeOpacity={0.85} style={[styles.mediaPad, styles.videoBox]}>
47
+ <View style={styles.playOverlay}>
48
+ <Text style={styles.playIcon}>▶</Text>
49
+ </View>
50
+ {caption ? <Text style={[styles.caption, { color: textColor }]}>{caption}</Text> : null}
51
+ </TouchableOpacity>
52
+ );
53
+ }
54
+
55
+ // ── audio ─────────────────────────────────────────────────────────────────────
56
+
57
+ function AudioRenderer({ url }: { url: string }) {
58
+ return (
59
+ <TouchableOpacity onPress={() => Linking.openURL(url)} activeOpacity={0.85} style={styles.audioRow}>
60
+ <Text style={styles.audioIcon}>🎵</Text>
61
+ <Text style={styles.audioLabel}>Audio message</Text>
62
+ </TouchableOpacity>
63
+ );
64
+ }
65
+
66
+ // ── document ──────────────────────────────────────────────────────────────────
67
+
68
+ function DocumentRenderer({ url, filename, textColor }: { url: string; filename: string; textColor: string }) {
69
+ return (
70
+ <TouchableOpacity onPress={() => Linking.openURL(url)} activeOpacity={0.85} style={styles.docRow}>
71
+ <Text style={styles.docIcon}>📄</Text>
72
+ <Text style={[styles.docName, { color: textColor }]} numberOfLines={1}>{filename}</Text>
73
+ <Text style={[styles.docDownload, { color: textColor }]}>↓</Text>
74
+ </TouchableOpacity>
75
+ );
76
+ }
77
+
78
+ // ── interactive ───────────────────────────────────────────────────────────────
79
+
80
+ function InteractiveRenderer({
81
+ message,
82
+ textColor,
83
+ fontSize,
84
+ onReply,
85
+ onShowListPicker,
86
+ disabled,
87
+ }: {
88
+ message: any;
89
+ textColor: string;
90
+ fontSize: number;
91
+ onReply?: (payload: InteractiveReplyPayload) => void;
92
+ onShowListPicker?: ShowListPickerFn;
93
+ disabled?: boolean;
94
+ }) {
95
+ const interactive = message?.livechat?.interactive;
96
+ if (!interactive) return null;
97
+ const { type } = interactive;
98
+ const messageId: string = message?._id ?? "";
99
+
100
+ // contact replies — display only
101
+ if (type === "button_reply") {
102
+ return <Text style={[styles.text, { color: textColor, fontSize }]}>{interactive.button_reply?.title}</Text>;
103
+ }
104
+ if (type === "list_reply") {
105
+ return <Text style={[styles.text, { color: textColor, fontSize }]}>{interactive.list_reply?.title}</Text>;
106
+ }
107
+ if (type === "nfm_reply") {
108
+ let parsed: Record<string, any> | null = null;
109
+ try { parsed = JSON.parse(interactive.nfm_reply?.response_json ?? ""); } catch { /* ignore */ }
110
+ const entries = parsed ? Object.entries(parsed).filter(([k]) => k !== "flow_token") : [];
111
+ return (
112
+ <View style={styles.pad}>
113
+ {entries.length === 0
114
+ ? <Text style={[styles.text, { color: textColor, fontSize }]}>Form submitted.</Text>
115
+ : entries.map(([k, v]) => (
116
+ <Text key={k} style={[styles.text, { color: textColor, fontSize }]}>
117
+ <Text style={{ fontWeight: "600" }}>{k.replace(/^screen_\d+_/, "").replace(/_/g, " ")}: </Text>
118
+ {String(v).replace(/^\d+_/, "").replace(/_-_/g, " - ").replace(/_/g, " ")}
119
+ </Text>
120
+ ))
121
+ }
122
+ </View>
123
+ );
124
+ }
125
+
126
+ // agent-sent interactive
127
+ const bodyText = interactive.body?.text ?? null;
128
+ const footerText = interactive.footer?.text ?? null;
129
+ const headerText = interactive.header?.type === "text" ? interactive.header.text : null;
130
+
131
+ if (type === "cta_url") {
132
+ const url = interactive.action?.parameters?.url;
133
+ const label = interactive.action?.parameters?.display_text;
134
+ return (
135
+ <View>
136
+ {headerText ? <Text style={[styles.iHeader, { color: textColor }]}>{headerText}</Text> : null}
137
+ {bodyText ? <Text style={[styles.text, { color: textColor, fontSize }]}>{bodyText}</Text> : null}
138
+ {footerText ? <Text style={[styles.iFooter, { color: textColor }]}>{footerText}</Text> : null}
139
+ <View style={[styles.divider, { borderTopColor: textColor + "22" }]} />
140
+ <TouchableOpacity onPress={() => url && Linking.openURL(url)} style={styles.iActionBtn} disabled={!url}>
141
+ <Text style={[styles.iActionText, { color: textColor, fontSize }]}>🔗 {label ?? url}</Text>
142
+ </TouchableOpacity>
143
+ </View>
144
+ );
145
+ }
146
+
147
+ if (type === "list") {
148
+ const sections: any[] = interactive.action?.sections ?? [];
149
+ const btnLabel = interactive.action?.button;
150
+ return (
151
+ <View>
152
+ {headerText ? <Text style={[styles.iHeader, { color: textColor }]}>{headerText}</Text> : null}
153
+ {bodyText ? <Text style={[styles.text, { color: textColor, fontSize }]}>{bodyText}</Text> : null}
154
+ {footerText ? <Text style={[styles.iFooter, { color: textColor }]}>{footerText}</Text> : null}
155
+ <View style={[styles.divider, { borderTopColor: textColor + "22" }]} />
156
+ <TouchableOpacity
157
+ style={styles.iActionBtn}
158
+ disabled={disabled || !onReply}
159
+ onPress={() => onShowListPicker && onReply && onShowListPicker(sections, (id, title) => onReply({ type: "list_reply", id, title, messageId }))}
160
+ >
161
+ <Text style={[styles.iActionText, { color: textColor, fontSize }]}>☰ {btnLabel}</Text>
162
+ </TouchableOpacity>
163
+ </View>
164
+ );
165
+ }
166
+
167
+ if (type === "button") {
168
+ const buttons: any[] = interactive.action?.buttons ?? [];
169
+ return (
170
+ <View>
171
+ {headerText ? <Text style={[styles.iHeader, { color: textColor }]}>{headerText}</Text> : null}
172
+ {bodyText ? <Text style={[styles.text, { color: textColor, fontSize }]}>{bodyText}</Text> : null}
173
+ {footerText ? <Text style={[styles.iFooter, { color: textColor }]}>{footerText}</Text> : null}
174
+ {buttons.map((btn: any, i: number) => {
175
+ const id = btn?.reply?.id ?? String(i);
176
+ const title = btn?.reply?.title ?? btn?.text ?? "";
177
+ return (
178
+ <View key={i}>
179
+ <View style={[styles.divider, { borderTopColor: textColor + "22" }]} />
180
+ <TouchableOpacity
181
+ style={styles.iActionBtn}
182
+ disabled={disabled || !onReply}
183
+ onPress={() => onReply?.({ type: "button_reply", id, title, messageId })}
184
+ >
185
+ <Text style={[styles.iActionText, { color: textColor, fontSize }]}>↩ {title}</Text>
186
+ </TouchableOpacity>
187
+ </View>
188
+ );
189
+ })}
190
+ </View>
191
+ );
192
+ }
193
+
194
+ // fallback
195
+ return bodyText ? <Text style={[styles.text, { color: textColor, fontSize }]}>{bodyText}</Text> : null;
196
+ }
197
+
198
+ // ── main renderer ─────────────────────────────────────────────────────────────
199
+
200
+ export type LivechatMessageRendererProps = {
201
+ message: any;
202
+ textColor: string;
203
+ fontSize: number;
204
+ onInteractiveReply?: (payload: InteractiveReplyPayload) => void;
205
+ onShowListPicker?: ShowListPickerFn;
206
+ disabled?: boolean;
207
+ };
208
+
209
+ export function LivechatMessageRenderer({
210
+ message,
211
+ textColor,
212
+ fontSize,
213
+ onInteractiveReply,
214
+ onShowListPicker,
215
+ disabled,
216
+ }: LivechatMessageRendererProps) {
217
+ const lc = message?.livechat;
218
+ if (!lc) return null;
219
+
220
+ switch (lc.type) {
221
+ case "text":
222
+ return <TextRenderer body={lc.text?.body ?? ""} textColor={textColor} fontSize={fontSize} />;
223
+ case "image":
224
+ return <ImageRenderer url={lc.image?.link ?? lc.image?.url ?? ""} caption={lc.image?.caption} textColor={textColor} />;
225
+ case "video":
226
+ return <VideoRenderer url={lc.video?.link ?? lc.video?.url ?? ""} caption={lc.video?.caption} textColor={textColor} />;
227
+ case "audio":
228
+ return <AudioRenderer url={lc.audio?.link ?? lc.audio?.url ?? ""} />;
229
+ case "document":
230
+ return <DocumentRenderer url={lc.document?.link ?? lc.document?.url ?? ""} filename={lc.document?.filename ?? message?.fileName ?? "Document"} textColor={textColor} />;
231
+ case "interactive":
232
+ return (
233
+ <InteractiveRenderer
234
+ message={message}
235
+ textColor={textColor}
236
+ fontSize={fontSize}
237
+ onReply={onInteractiveReply}
238
+ onShowListPicker={onShowListPicker}
239
+ disabled={disabled}
240
+ />
241
+ );
242
+ default:
243
+ return <Text style={[styles.text, { color: textColor, fontSize }]}>[Message]</Text>;
244
+ }
245
+ }
246
+
247
+ const styles = StyleSheet.create({
248
+ text: { lineHeight: 22, paddingHorizontal: 12, paddingVertical: 8, flexShrink: 1 },
249
+ pad: { paddingHorizontal: 12, paddingVertical: 8 },
250
+ caption: { fontSize: 12, paddingHorizontal: 8, paddingBottom: 6, marginTop: 2 },
251
+ mediaPad: { padding: 4 },
252
+ mediaImage: { width: 260, height: 180, borderRadius: 8 },
253
+ videoBox: { width: 260, height: 160, backgroundColor: "#000", borderRadius: 8, alignItems: "center", justifyContent: "center" },
254
+ playOverlay: { width: 48, height: 48, borderRadius: 24, backgroundColor: "rgba(255,255,255,0.85)", alignItems: "center", justifyContent: "center" },
255
+ playIcon: { fontSize: 20, marginLeft: 3 },
256
+ audioRow: { flexDirection: "row", alignItems: "center", paddingHorizontal: 12, paddingVertical: 10, gap: 8 },
257
+ audioIcon: { fontSize: 18 },
258
+ audioLabel: { fontSize: 13, color: "#71717a" },
259
+ docRow: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 12, paddingVertical: 10, maxWidth: 260 },
260
+ docIcon: { fontSize: 20 },
261
+ docName: { flex: 1, fontSize: 13 },
262
+ docDownload: { fontSize: 16, fontWeight: "600" },
263
+ iHeader: { fontSize: 14, fontWeight: "600", paddingHorizontal: 12, paddingTop: 10, paddingBottom: 2 },
264
+ iFooter: { fontSize: 11, fontStyle: "italic", paddingHorizontal: 12, paddingBottom: 6, opacity: 0.6 },
265
+ divider: { borderTopWidth: StyleSheet.hairlineWidth, marginHorizontal: 0 },
266
+ iActionBtn: { paddingHorizontal: 12, paddingVertical: 10, alignItems: "center" },
267
+ iActionText: { fontWeight: "500", textAlign: "center" },
268
+ });
@@ -0,0 +1,88 @@
1
+ import React from "react";
2
+ import { StyleSheet, Text, View } from "react-native";
3
+ import { useLiveChatContext } from "../../provider/LiveChatContext";
4
+
5
+ function pickActorEntity(message: any) {
6
+ const actor = message?.log?.actor;
7
+ if (!actor?.type) return null;
8
+ if (actor.type === "user") return actor.userId;
9
+ if (actor.type === "bot") return actor.botId;
10
+ if (actor.type === "ai") return actor.aiId;
11
+ return null;
12
+ }
13
+
14
+ function assignerDisplayName(message: any): string {
15
+ const entity = pickActorEntity(message);
16
+ const n =
17
+ typeof entity?.name === "string"
18
+ ? entity.name.trim()
19
+ : typeof entity?.displayName === "string"
20
+ ? entity.displayName.trim()
21
+ : "";
22
+ if (n) return n;
23
+
24
+ // fallback: parse from raw body string e.g. "jagan has joined the chat"
25
+ const body = message?.livechat?.text?.body ?? message?.content ?? "";
26
+ const joinMatch = body.match(/^(.+?) has joined/i);
27
+ if (joinMatch) return joinMatch[1].trim();
28
+ const closeMatch = body.match(/^(.+?) has closed/i);
29
+ if (closeMatch) return closeMatch[1].trim();
30
+ const leftMatch = body.match(/^(.+?) has left/i);
31
+ if (leftMatch) return leftMatch[1].trim();
32
+
33
+ return message?.userId?.name ?? message?.actorName ?? "Someone";
34
+ }
35
+
36
+ function resolveSuffix(message: any): string | null {
37
+ const event = message?.log?.action?.event;
38
+
39
+ if (event === "marked_as_spam") return null;
40
+ if (event === "unassigned") return "has left the chat";
41
+ if (event === "resolved") return "has closed the chat";
42
+ if (event === "assigned") return "has joined the chat";
43
+
44
+ if (event) {
45
+ const assign = message?.log?.action?.assign;
46
+ if (assign) return "has joined the chat";
47
+ return null;
48
+ }
49
+
50
+ // fallback: parse from raw body
51
+ const body = message?.livechat?.text?.body ?? message?.content ?? "";
52
+ const logType = message?.livechat?.logType ?? message?.logType ?? "";
53
+ if (logType === "resolved" || body.toLowerCase().includes("closed")) return "has closed the chat";
54
+ if (logType === "unassigned" || body.toLowerCase().includes("left")) return "has left the chat";
55
+ if (logType === "assigned" || body.toLowerCase().includes("joined")) return "has joined the chat";
56
+
57
+ return body || null;
58
+ }
59
+
60
+ export type LogMessageProps = {
61
+ message: any;
62
+ };
63
+
64
+ export function LogMessage({ message }: LogMessageProps) {
65
+ const { theme: t } = useLiveChatContext();
66
+ const suffix = resolveSuffix(message);
67
+ if (!suffix) return null;
68
+
69
+ const name = assignerDisplayName(message);
70
+
71
+ return (
72
+ <View style={styles.row}>
73
+ <Text style={[styles.text, { color: t.colors.textMuted }]}>
74
+ {name ? (
75
+ <>
76
+ <Text style={{ color: t.colors.text, fontWeight: "600" }}>{name}</Text>
77
+ {" " + suffix}
78
+ </>
79
+ ) : suffix}
80
+ </Text>
81
+ </View>
82
+ );
83
+ }
84
+
85
+ const styles = StyleSheet.create({
86
+ row: { alignItems: "center", paddingVertical: 8, paddingHorizontal: 16 },
87
+ text: { textAlign: "center", fontSize: 12, lineHeight: 18 },
88
+ });
@@ -0,0 +1,148 @@
1
+ import React from "react";
2
+ import { StyleSheet, Text, View } from "react-native";
3
+ import { useLiveChatContext } from "../../provider/LiveChatContext";
4
+ import { LivechatMessageRenderer } from "./LivechatMessageRenderer";
5
+ import type { InteractiveReplyPayload, ShowListPickerFn } from "./LivechatMessageRenderer";
6
+
7
+ export type BubblePosition = "single" | "first" | "middle" | "last";
8
+
9
+ export type MessageBubbleProps = {
10
+ message: any;
11
+ isOutgoing?: boolean;
12
+ position?: BubblePosition;
13
+ showFooter?: boolean;
14
+ senderName?: string;
15
+ roleLabel?: string;
16
+ onInteractiveReply?: (payload: InteractiveReplyPayload | string) => void;
17
+ onShowListPicker?: ShowListPickerFn;
18
+ disabled?: boolean;
19
+ };
20
+
21
+ // ── bubble radius — mirrors web contact/agent bubble radius logic ─────────────
22
+
23
+ function getBubbleRadius(position: BubblePosition, outgoing: boolean, base: number) {
24
+ const sm = 5;
25
+ if (outgoing) {
26
+ switch (position) {
27
+ case "first": return { borderTopLeftRadius: base, borderTopRightRadius: base, borderBottomLeftRadius: base, borderBottomRightRadius: sm };
28
+ case "middle": return { borderTopLeftRadius: base, borderTopRightRadius: sm, borderBottomLeftRadius: base, borderBottomRightRadius: sm };
29
+ case "last": return { borderTopLeftRadius: base, borderTopRightRadius: sm, borderBottomLeftRadius: base, borderBottomRightRadius: base };
30
+ default: return { borderTopLeftRadius: base, borderTopRightRadius: base, borderBottomLeftRadius: base, borderBottomRightRadius: base };
31
+ }
32
+ } else {
33
+ switch (position) {
34
+ case "first": return { borderTopLeftRadius: base, borderTopRightRadius: base, borderBottomLeftRadius: sm, borderBottomRightRadius: base };
35
+ case "middle": return { borderTopLeftRadius: sm, borderTopRightRadius: base, borderBottomLeftRadius: sm, borderBottomRightRadius: base };
36
+ case "last": return { borderTopLeftRadius: sm, borderTopRightRadius: base, borderBottomLeftRadius: base, borderBottomRightRadius: base };
37
+ default: return { borderTopLeftRadius: base, borderTopRightRadius: base, borderBottomLeftRadius: base, borderBottomRightRadius: base };
38
+ }
39
+ }
40
+ }
41
+
42
+ // ── footer — mirrors web buildAgentMeta / buildContactMeta ───────────────────
43
+
44
+ function formatMetaRelativeTime(dateStr?: string): string {
45
+ if (!dateStr) return "now";
46
+ const d = new Date(dateStr);
47
+ if (isNaN(d.getTime())) return "now";
48
+ const diff = Date.now() - d.getTime();
49
+ if (diff < 60_000) return "now";
50
+ const m = Math.floor(diff / 60_000);
51
+ if (m < 60) return `${m}m`;
52
+ const h = Math.floor(m / 60);
53
+ if (h < 24) return `${h}h`;
54
+ const day = Math.floor(h / 24);
55
+ if (day < 7) return `${day}d`;
56
+ return d.toLocaleDateString([], { month: "short", day: "numeric" });
57
+ }
58
+
59
+ function GroupFooter({
60
+ message,
61
+ outgoing,
62
+ mutedColor,
63
+ senderName,
64
+ roleLabel,
65
+ }: {
66
+ message: any;
67
+ outgoing: boolean;
68
+ mutedColor: string;
69
+ senderName?: string;
70
+ roleLabel?: string;
71
+ }) {
72
+ const dateStr = message?.createdAt || message?.livechat?.timestamp;
73
+ const time = formatMetaRelativeTime(dateStr);
74
+
75
+ if (outgoing) {
76
+ return <Text style={[styles.footerText, styles.footerRight, { color: mutedColor }]}>{time}</Text>;
77
+ }
78
+
79
+ // mirrors web buildAgentMeta: name • role • time
80
+ const parts = [senderName, roleLabel, time].filter(Boolean).join(" \u2022 ");
81
+ return <Text style={[styles.footerText, { color: mutedColor }]} numberOfLines={1}>{parts}</Text>;
82
+ }
83
+
84
+ // ── main component ────────────────────────────────────────────────────────────
85
+
86
+ export function MessageBubble({
87
+ message,
88
+ isOutgoing,
89
+ position = "single",
90
+ showFooter = false,
91
+ senderName,
92
+ roleLabel,
93
+ onInteractiveReply,
94
+ onShowListPicker,
95
+ disabled,
96
+ }: MessageBubbleProps) {
97
+ const { theme: t } = useLiveChatContext();
98
+
99
+ const outgoing = isOutgoing !== undefined ? isOutgoing : message?.senderType === "contact";
100
+
101
+ const bubbleBg = outgoing
102
+ ? t.useBrandThemingForChat ? t.colors.brand : t.colors.bubbleOut
103
+ : t.colors.bubbleIn;
104
+
105
+ const textColor = outgoing ? t.colors.bubbleTextOut : t.colors.bubbleTextIn;
106
+ const radiusStyle = getBubbleRadius(position, outgoing, t.borderRadius.bubble);
107
+ const marginBottom = position === "single" || position === "last" ? t.spacing.sm : t.spacing.xs / 2;
108
+
109
+ // normalize onInteractiveReply — web passes full payload, legacy passes string label
110
+ const handleInteractiveReply = onInteractiveReply
111
+ ? (payload: InteractiveReplyPayload) => onInteractiveReply(payload)
112
+ : undefined;
113
+
114
+ return (
115
+ <View style={{ marginBottom }}>
116
+ <View style={[styles.row, outgoing ? styles.rowRight : styles.rowLeft]}>
117
+ <View style={[styles.bubble, radiusStyle, { backgroundColor: bubbleBg, maxWidth: "78%" }]}>
118
+ <LivechatMessageRenderer
119
+ message={message}
120
+ textColor={textColor}
121
+ fontSize={t.fontSizes.md}
122
+ onInteractiveReply={handleInteractiveReply}
123
+ onShowListPicker={onShowListPicker}
124
+ disabled={disabled}
125
+ />
126
+ </View>
127
+ </View>
128
+ {showFooter ? (
129
+ <GroupFooter
130
+ message={message}
131
+ outgoing={outgoing}
132
+ mutedColor={t.colors.textMuted}
133
+ senderName={senderName}
134
+ roleLabel={roleLabel}
135
+ />
136
+ ) : null}
137
+ </View>
138
+ );
139
+ }
140
+
141
+ const styles = StyleSheet.create({
142
+ row: { flexDirection: "row", paddingHorizontal: 12, marginVertical: 1 },
143
+ rowLeft: { justifyContent: "flex-start" },
144
+ rowRight: { justifyContent: "flex-end" },
145
+ bubble: { overflow: "hidden" },
146
+ footerText: { fontSize: 11, color: "#71717a", paddingHorizontal: 16, marginTop: 2, marginBottom: 2 },
147
+ footerRight: { textAlign: "right" },
148
+ });