@parlr/react-native 0.1.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 (223) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +918 -0
  3. package/lib/commonjs/components/AttachmentPicker.js +292 -0
  4. package/lib/commonjs/components/AttachmentPicker.js.map +1 -0
  5. package/lib/commonjs/components/AttachmentPreview.js +200 -0
  6. package/lib/commonjs/components/AttachmentPreview.js.map +1 -0
  7. package/lib/commonjs/components/ChatBubble.js +391 -0
  8. package/lib/commonjs/components/ChatBubble.js.map +1 -0
  9. package/lib/commonjs/components/EmptyState.js +115 -0
  10. package/lib/commonjs/components/EmptyState.js.map +1 -0
  11. package/lib/commonjs/components/ParlrChat.js +745 -0
  12. package/lib/commonjs/components/ParlrChat.js.map +1 -0
  13. package/lib/commonjs/components/ParlrConversationList.js +509 -0
  14. package/lib/commonjs/components/ParlrConversationList.js.map +1 -0
  15. package/lib/commonjs/components/PreChatForm.js +263 -0
  16. package/lib/commonjs/components/PreChatForm.js.map +1 -0
  17. package/lib/commonjs/components/RichMessage.js +284 -0
  18. package/lib/commonjs/components/RichMessage.js.map +1 -0
  19. package/lib/commonjs/components/SatisfactionSurvey.js +292 -0
  20. package/lib/commonjs/components/SatisfactionSurvey.js.map +1 -0
  21. package/lib/commonjs/components/TypingIndicator.js +86 -0
  22. package/lib/commonjs/components/TypingIndicator.js.map +1 -0
  23. package/lib/commonjs/core/api.js +310 -0
  24. package/lib/commonjs/core/api.js.map +1 -0
  25. package/lib/commonjs/core/config.js +40 -0
  26. package/lib/commonjs/core/config.js.map +1 -0
  27. package/lib/commonjs/core/errors.js +73 -0
  28. package/lib/commonjs/core/errors.js.map +1 -0
  29. package/lib/commonjs/core/offlineQueue.js +89 -0
  30. package/lib/commonjs/core/offlineQueue.js.map +1 -0
  31. package/lib/commonjs/core/pushNotifications.js +21 -0
  32. package/lib/commonjs/core/pushNotifications.js.map +1 -0
  33. package/lib/commonjs/core/session.js +130 -0
  34. package/lib/commonjs/core/session.js.map +1 -0
  35. package/lib/commonjs/core/theme.js +110 -0
  36. package/lib/commonjs/core/theme.js.map +1 -0
  37. package/lib/commonjs/core/types.js +6 -0
  38. package/lib/commonjs/core/types.js.map +1 -0
  39. package/lib/commonjs/core/websocket.js +245 -0
  40. package/lib/commonjs/core/websocket.js.map +1 -0
  41. package/lib/commonjs/hooks/useChat.js +462 -0
  42. package/lib/commonjs/hooks/useChat.js.map +1 -0
  43. package/lib/commonjs/hooks/useParlr.js +44 -0
  44. package/lib/commonjs/hooks/useParlr.js.map +1 -0
  45. package/lib/commonjs/index.js +185 -0
  46. package/lib/commonjs/index.js.map +1 -0
  47. package/lib/commonjs/package.json +1 -0
  48. package/lib/commonjs/provider/ParlrContext.js +38 -0
  49. package/lib/commonjs/provider/ParlrContext.js.map +1 -0
  50. package/lib/commonjs/provider/ParlrProvider.js +256 -0
  51. package/lib/commonjs/provider/ParlrProvider.js.map +1 -0
  52. package/lib/module/components/AttachmentPicker.js +287 -0
  53. package/lib/module/components/AttachmentPicker.js.map +1 -0
  54. package/lib/module/components/AttachmentPreview.js +195 -0
  55. package/lib/module/components/AttachmentPreview.js.map +1 -0
  56. package/lib/module/components/ChatBubble.js +386 -0
  57. package/lib/module/components/ChatBubble.js.map +1 -0
  58. package/lib/module/components/EmptyState.js +110 -0
  59. package/lib/module/components/EmptyState.js.map +1 -0
  60. package/lib/module/components/ParlrChat.js +740 -0
  61. package/lib/module/components/ParlrChat.js.map +1 -0
  62. package/lib/module/components/ParlrConversationList.js +504 -0
  63. package/lib/module/components/ParlrConversationList.js.map +1 -0
  64. package/lib/module/components/PreChatForm.js +258 -0
  65. package/lib/module/components/PreChatForm.js.map +1 -0
  66. package/lib/module/components/RichMessage.js +280 -0
  67. package/lib/module/components/RichMessage.js.map +1 -0
  68. package/lib/module/components/SatisfactionSurvey.js +287 -0
  69. package/lib/module/components/SatisfactionSurvey.js.map +1 -0
  70. package/lib/module/components/TypingIndicator.js +81 -0
  71. package/lib/module/components/TypingIndicator.js.map +1 -0
  72. package/lib/module/core/api.js +305 -0
  73. package/lib/module/core/api.js.map +1 -0
  74. package/lib/module/core/config.js +36 -0
  75. package/lib/module/core/config.js.map +1 -0
  76. package/lib/module/core/errors.js +64 -0
  77. package/lib/module/core/errors.js.map +1 -0
  78. package/lib/module/core/offlineQueue.js +82 -0
  79. package/lib/module/core/offlineQueue.js.map +1 -0
  80. package/lib/module/core/pushNotifications.js +16 -0
  81. package/lib/module/core/pushNotifications.js.map +1 -0
  82. package/lib/module/core/session.js +122 -0
  83. package/lib/module/core/session.js.map +1 -0
  84. package/lib/module/core/theme.js +105 -0
  85. package/lib/module/core/theme.js.map +1 -0
  86. package/lib/module/core/types.js +4 -0
  87. package/lib/module/core/types.js.map +1 -0
  88. package/lib/module/core/websocket.js +241 -0
  89. package/lib/module/core/websocket.js.map +1 -0
  90. package/lib/module/hooks/useChat.js +458 -0
  91. package/lib/module/hooks/useChat.js.map +1 -0
  92. package/lib/module/hooks/useParlr.js +40 -0
  93. package/lib/module/hooks/useParlr.js.map +1 -0
  94. package/lib/module/index.js +58 -0
  95. package/lib/module/index.js.map +1 -0
  96. package/lib/module/package.json +1 -0
  97. package/lib/module/provider/ParlrContext.js +35 -0
  98. package/lib/module/provider/ParlrContext.js.map +1 -0
  99. package/lib/module/provider/ParlrProvider.js +251 -0
  100. package/lib/module/provider/ParlrProvider.js.map +1 -0
  101. package/lib/typescript/commonjs/components/AttachmentPicker.d.ts +23 -0
  102. package/lib/typescript/commonjs/components/AttachmentPicker.d.ts.map +1 -0
  103. package/lib/typescript/commonjs/components/AttachmentPreview.d.ts +16 -0
  104. package/lib/typescript/commonjs/components/AttachmentPreview.d.ts.map +1 -0
  105. package/lib/typescript/commonjs/components/ChatBubble.d.ts +14 -0
  106. package/lib/typescript/commonjs/components/ChatBubble.d.ts.map +1 -0
  107. package/lib/typescript/commonjs/components/EmptyState.d.ts +10 -0
  108. package/lib/typescript/commonjs/components/EmptyState.d.ts.map +1 -0
  109. package/lib/typescript/commonjs/components/ParlrChat.d.ts +34 -0
  110. package/lib/typescript/commonjs/components/ParlrChat.d.ts.map +1 -0
  111. package/lib/typescript/commonjs/components/ParlrConversationList.d.ts +17 -0
  112. package/lib/typescript/commonjs/components/ParlrConversationList.d.ts.map +1 -0
  113. package/lib/typescript/commonjs/components/PreChatForm.d.ts +20 -0
  114. package/lib/typescript/commonjs/components/PreChatForm.d.ts.map +1 -0
  115. package/lib/typescript/commonjs/components/RichMessage.d.ts +41 -0
  116. package/lib/typescript/commonjs/components/RichMessage.d.ts.map +1 -0
  117. package/lib/typescript/commonjs/components/SatisfactionSurvey.d.ts +17 -0
  118. package/lib/typescript/commonjs/components/SatisfactionSurvey.d.ts.map +1 -0
  119. package/lib/typescript/commonjs/components/TypingIndicator.d.ts +7 -0
  120. package/lib/typescript/commonjs/components/TypingIndicator.d.ts.map +1 -0
  121. package/lib/typescript/commonjs/core/api.d.ts +37 -0
  122. package/lib/typescript/commonjs/core/api.d.ts.map +1 -0
  123. package/lib/typescript/commonjs/core/config.d.ts +9 -0
  124. package/lib/typescript/commonjs/core/config.d.ts.map +1 -0
  125. package/lib/typescript/commonjs/core/errors.d.ts +35 -0
  126. package/lib/typescript/commonjs/core/errors.d.ts.map +1 -0
  127. package/lib/typescript/commonjs/core/offlineQueue.d.ts +16 -0
  128. package/lib/typescript/commonjs/core/offlineQueue.d.ts.map +1 -0
  129. package/lib/typescript/commonjs/core/pushNotifications.d.ts +6 -0
  130. package/lib/typescript/commonjs/core/pushNotifications.d.ts.map +1 -0
  131. package/lib/typescript/commonjs/core/session.d.ts +15 -0
  132. package/lib/typescript/commonjs/core/session.d.ts.map +1 -0
  133. package/lib/typescript/commonjs/core/theme.d.ts +43 -0
  134. package/lib/typescript/commonjs/core/theme.d.ts.map +1 -0
  135. package/lib/typescript/commonjs/core/types.d.ts +185 -0
  136. package/lib/typescript/commonjs/core/types.d.ts.map +1 -0
  137. package/lib/typescript/commonjs/core/websocket.d.ts +17 -0
  138. package/lib/typescript/commonjs/core/websocket.d.ts.map +1 -0
  139. package/lib/typescript/commonjs/hooks/useChat.d.ts +35 -0
  140. package/lib/typescript/commonjs/hooks/useChat.d.ts.map +1 -0
  141. package/lib/typescript/commonjs/hooks/useParlr.d.ts +11 -0
  142. package/lib/typescript/commonjs/hooks/useParlr.d.ts.map +1 -0
  143. package/lib/typescript/commonjs/index.d.ts +30 -0
  144. package/lib/typescript/commonjs/index.d.ts.map +1 -0
  145. package/lib/typescript/commonjs/package.json +1 -0
  146. package/lib/typescript/commonjs/provider/ParlrContext.d.ts +13 -0
  147. package/lib/typescript/commonjs/provider/ParlrContext.d.ts.map +1 -0
  148. package/lib/typescript/commonjs/provider/ParlrProvider.d.ts +5 -0
  149. package/lib/typescript/commonjs/provider/ParlrProvider.d.ts.map +1 -0
  150. package/lib/typescript/module/components/AttachmentPicker.d.ts +23 -0
  151. package/lib/typescript/module/components/AttachmentPicker.d.ts.map +1 -0
  152. package/lib/typescript/module/components/AttachmentPreview.d.ts +16 -0
  153. package/lib/typescript/module/components/AttachmentPreview.d.ts.map +1 -0
  154. package/lib/typescript/module/components/ChatBubble.d.ts +14 -0
  155. package/lib/typescript/module/components/ChatBubble.d.ts.map +1 -0
  156. package/lib/typescript/module/components/EmptyState.d.ts +10 -0
  157. package/lib/typescript/module/components/EmptyState.d.ts.map +1 -0
  158. package/lib/typescript/module/components/ParlrChat.d.ts +34 -0
  159. package/lib/typescript/module/components/ParlrChat.d.ts.map +1 -0
  160. package/lib/typescript/module/components/ParlrConversationList.d.ts +17 -0
  161. package/lib/typescript/module/components/ParlrConversationList.d.ts.map +1 -0
  162. package/lib/typescript/module/components/PreChatForm.d.ts +20 -0
  163. package/lib/typescript/module/components/PreChatForm.d.ts.map +1 -0
  164. package/lib/typescript/module/components/RichMessage.d.ts +41 -0
  165. package/lib/typescript/module/components/RichMessage.d.ts.map +1 -0
  166. package/lib/typescript/module/components/SatisfactionSurvey.d.ts +17 -0
  167. package/lib/typescript/module/components/SatisfactionSurvey.d.ts.map +1 -0
  168. package/lib/typescript/module/components/TypingIndicator.d.ts +7 -0
  169. package/lib/typescript/module/components/TypingIndicator.d.ts.map +1 -0
  170. package/lib/typescript/module/core/api.d.ts +37 -0
  171. package/lib/typescript/module/core/api.d.ts.map +1 -0
  172. package/lib/typescript/module/core/config.d.ts +9 -0
  173. package/lib/typescript/module/core/config.d.ts.map +1 -0
  174. package/lib/typescript/module/core/errors.d.ts +35 -0
  175. package/lib/typescript/module/core/errors.d.ts.map +1 -0
  176. package/lib/typescript/module/core/offlineQueue.d.ts +16 -0
  177. package/lib/typescript/module/core/offlineQueue.d.ts.map +1 -0
  178. package/lib/typescript/module/core/pushNotifications.d.ts +6 -0
  179. package/lib/typescript/module/core/pushNotifications.d.ts.map +1 -0
  180. package/lib/typescript/module/core/session.d.ts +15 -0
  181. package/lib/typescript/module/core/session.d.ts.map +1 -0
  182. package/lib/typescript/module/core/theme.d.ts +43 -0
  183. package/lib/typescript/module/core/theme.d.ts.map +1 -0
  184. package/lib/typescript/module/core/types.d.ts +185 -0
  185. package/lib/typescript/module/core/types.d.ts.map +1 -0
  186. package/lib/typescript/module/core/websocket.d.ts +17 -0
  187. package/lib/typescript/module/core/websocket.d.ts.map +1 -0
  188. package/lib/typescript/module/hooks/useChat.d.ts +35 -0
  189. package/lib/typescript/module/hooks/useChat.d.ts.map +1 -0
  190. package/lib/typescript/module/hooks/useParlr.d.ts +11 -0
  191. package/lib/typescript/module/hooks/useParlr.d.ts.map +1 -0
  192. package/lib/typescript/module/index.d.ts +30 -0
  193. package/lib/typescript/module/index.d.ts.map +1 -0
  194. package/lib/typescript/module/package.json +1 -0
  195. package/lib/typescript/module/provider/ParlrContext.d.ts +13 -0
  196. package/lib/typescript/module/provider/ParlrContext.d.ts.map +1 -0
  197. package/lib/typescript/module/provider/ParlrProvider.d.ts +5 -0
  198. package/lib/typescript/module/provider/ParlrProvider.d.ts.map +1 -0
  199. package/package.json +120 -0
  200. package/src/components/AttachmentPicker.tsx +310 -0
  201. package/src/components/AttachmentPreview.tsx +209 -0
  202. package/src/components/ChatBubble.tsx +424 -0
  203. package/src/components/EmptyState.tsx +118 -0
  204. package/src/components/ParlrChat.tsx +863 -0
  205. package/src/components/ParlrConversationList.tsx +559 -0
  206. package/src/components/PreChatForm.tsx +313 -0
  207. package/src/components/RichMessage.tsx +353 -0
  208. package/src/components/SatisfactionSurvey.tsx +333 -0
  209. package/src/components/TypingIndicator.tsx +89 -0
  210. package/src/core/api.ts +406 -0
  211. package/src/core/config.ts +39 -0
  212. package/src/core/errors.ts +68 -0
  213. package/src/core/offlineQueue.ts +94 -0
  214. package/src/core/pushNotifications.ts +22 -0
  215. package/src/core/session.ts +156 -0
  216. package/src/core/theme.ts +133 -0
  217. package/src/core/types.ts +237 -0
  218. package/src/core/websocket.ts +270 -0
  219. package/src/hooks/useChat.ts +534 -0
  220. package/src/hooks/useParlr.ts +43 -0
  221. package/src/index.ts +98 -0
  222. package/src/provider/ParlrContext.ts +40 -0
  223. package/src/provider/ParlrProvider.tsx +338 -0
@@ -0,0 +1,863 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Parlr React Native SDK - ParlrChat (main component)
3
+ // ---------------------------------------------------------------------------
4
+ //
5
+ // A self-contained, production-quality chat screen. Drop it onto a screen
6
+ // and everything just works: session creation, WebSocket connection,
7
+ // conversation management, message sending, typing indicators.
8
+ //
9
+ // Features: attachments, conversation close/reopen, pre-chat form, CSAT.
10
+ // ---------------------------------------------------------------------------
11
+
12
+ import React, {
13
+ useCallback,
14
+ useContext,
15
+ useEffect,
16
+ useRef,
17
+ useState,
18
+ } from 'react';
19
+ import {
20
+ ActivityIndicator,
21
+ FlatList,
22
+ Keyboard,
23
+ Modal,
24
+ type NativeScrollEvent,
25
+ type NativeSyntheticEvent,
26
+ Platform,
27
+ Pressable,
28
+ StyleSheet,
29
+ Text,
30
+ TextInput,
31
+ View,
32
+ type ListRenderItemInfo,
33
+ } from 'react-native';
34
+ import Animated, {
35
+ useAnimatedStyle,
36
+ useSharedValue,
37
+ withSpring,
38
+ } from 'react-native-reanimated';
39
+ import type { ParlrTheme } from '../core/theme';
40
+ import type { ParlrUser, Message } from '../core/types';
41
+ import { useChat } from '../hooks/useChat';
42
+ import { ParlrContext } from '../provider/ParlrContext';
43
+ import { ChatBubble } from './ChatBubble';
44
+ import { EmptyState } from './EmptyState';
45
+ import { TypingIndicator } from './TypingIndicator';
46
+ import { PreChatForm } from './PreChatForm';
47
+ import { SatisfactionSurvey } from './SatisfactionSurvey';
48
+ import { AttachmentPicker } from './AttachmentPicker';
49
+ import type { PickedFile } from './AttachmentPicker';
50
+ import { AttachmentPreview } from './AttachmentPreview';
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Props
54
+ // ---------------------------------------------------------------------------
55
+
56
+ export interface ParlrChatProps {
57
+ /** Identify the user on mount (optional — can also call `useParlr().identify`). */
58
+ user?: ParlrUser;
59
+ /** Existing conversation id. If omitted, a new one is created on first message. */
60
+ conversationId?: string;
61
+ /** Back button handler. If provided, a back arrow is shown in the header. */
62
+ onBack?: () => void;
63
+ /** Header title. Default: "Support". */
64
+ headerTitle?: string;
65
+ /** Input placeholder text. */
66
+ placeholder?: string;
67
+ /** Empty state title. */
68
+ emptyStateTitle?: string;
69
+ /** Empty state description. */
70
+ emptyStateDescription?: string;
71
+ /** Primary accent color for contact bubbles, send button, etc. */
72
+ accentColor?: string;
73
+ /** Show the header bar. Default: true. */
74
+ showHeader?: boolean;
75
+ /** Show the pre-chat form if user is not identified. Default: false. */
76
+ showPreChatForm?: boolean;
77
+ /** Fields for pre-chat form. Default: ['name', 'email']. */
78
+ preChatFields?: Array<'name' | 'email' | 'phone'>;
79
+ /** Show CSAT survey after conversation is closed. Default: true. */
80
+ showSatisfactionSurvey?: boolean;
81
+ /** Called when conversation is closed. */
82
+ onConversationClosed?: () => void;
83
+ /** Bottom safe-area inset the host app applies (default 0). Set this to your bottom inset so the input bar sits flush above the keyboard. */
84
+ safeAreaBottom?: number;
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // i18n defaults
89
+ // ---------------------------------------------------------------------------
90
+
91
+ function getI18n(locale?: string) {
92
+ if (locale?.startsWith('en')) {
93
+ return {
94
+ headerTitle: 'Support',
95
+ placeholder: 'Write a message...',
96
+ online: 'Online',
97
+ offline: 'Offline',
98
+ closeConversation: 'Close conversation',
99
+ reopenConversation: 'Reopen conversation',
100
+ conversationClosed: 'This conversation has been closed.',
101
+ };
102
+ }
103
+ return {
104
+ headerTitle: 'Support',
105
+ placeholder: '\u00c9crivez un message...',
106
+ online: 'En ligne',
107
+ offline: 'Hors ligne',
108
+ closeConversation: 'Fermer la conversation',
109
+ reopenConversation: 'Rouvrir la conversation',
110
+ conversationClosed: 'Cette conversation a \u00e9t\u00e9 ferm\u00e9e.',
111
+ };
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Animated send button
116
+ // ---------------------------------------------------------------------------
117
+
118
+ function SendButton({
119
+ onPress,
120
+ disabled,
121
+ accentColor,
122
+ theme,
123
+ }: {
124
+ onPress: () => void;
125
+ disabled: boolean;
126
+ accentColor: string;
127
+ theme: ParlrTheme;
128
+ }) {
129
+ const scale = useSharedValue(1);
130
+
131
+ const animatedStyle = useAnimatedStyle(() => ({
132
+ transform: [{ scale: scale.value }],
133
+ }));
134
+
135
+ const handlePressIn = () => {
136
+ scale.value = withSpring(0.85, { damping: 15, stiffness: 300 });
137
+ };
138
+
139
+ const handlePressOut = () => {
140
+ scale.value = withSpring(1, { damping: 15, stiffness: 300 });
141
+ };
142
+
143
+ return (
144
+ <Pressable
145
+ onPress={onPress}
146
+ onPressIn={handlePressIn}
147
+ onPressOut={handlePressOut}
148
+ disabled={disabled}
149
+ hitSlop={8}
150
+ accessibilityRole="button"
151
+ accessibilityLabel="Send message"
152
+ accessibilityState={{ disabled }}
153
+ >
154
+ <Animated.View
155
+ style={[
156
+ styles.sendButton,
157
+ {
158
+ backgroundColor: disabled ? theme.colors.border : accentColor,
159
+ },
160
+ animatedStyle,
161
+ ]}
162
+ >
163
+ <Text style={[styles.sendIcon, { color: theme.colors.primaryText }]}>{'\u2191'}</Text>
164
+ </Animated.View>
165
+ </Pressable>
166
+ );
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Header menu (3-dot)
171
+ // ---------------------------------------------------------------------------
172
+
173
+ function HeaderMenu({
174
+ visible,
175
+ onDismiss,
176
+ onClose,
177
+ onReopen,
178
+ isClosed,
179
+ theme,
180
+ locale,
181
+ }: {
182
+ visible: boolean;
183
+ onDismiss: () => void;
184
+ onClose: () => void;
185
+ onReopen: () => void;
186
+ isClosed: boolean;
187
+ theme: ParlrTheme;
188
+ locale?: string;
189
+ }) {
190
+ const i18n = getI18n(locale);
191
+ if (!visible) return null;
192
+
193
+ return (
194
+ <Modal transparent animationType="fade" visible={visible} onRequestClose={onDismiss}>
195
+ <Pressable style={styles.menuBackdrop} onPress={onDismiss}>
196
+ <View style={[styles.menuContainer, { backgroundColor: theme.colors.surface }]}>
197
+ <Pressable
198
+ onPress={isClosed ? onReopen : onClose}
199
+ style={styles.menuItem}
200
+ accessibilityRole="button"
201
+ >
202
+ <Text style={[styles.menuItemText, { color: theme.colors.text }]}>
203
+ {isClosed ? i18n.reopenConversation : i18n.closeConversation}
204
+ </Text>
205
+ </Pressable>
206
+ </View>
207
+ </Pressable>
208
+ </Modal>
209
+ );
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Main component
214
+ // ---------------------------------------------------------------------------
215
+
216
+ export function ParlrChat({
217
+ user,
218
+ conversationId,
219
+ onBack,
220
+ headerTitle,
221
+ placeholder,
222
+ emptyStateTitle,
223
+ emptyStateDescription,
224
+ accentColor: accentColorProp,
225
+ showHeader = true,
226
+ showPreChatForm = false,
227
+ preChatFields = ['name', 'email'],
228
+ showSatisfactionSurvey = true,
229
+ onConversationClosed,
230
+ safeAreaBottom = 0,
231
+ }: ParlrChatProps) {
232
+ const { config, isReady, isConnected, session, identify, api, theme } = useContext(ParlrContext);
233
+ const accentColor = accentColorProp ?? theme.colors.primary;
234
+ const i18n = getI18n(config.locale);
235
+
236
+ // Consider "online" if session is active (REST API works), not just WebSocket.
237
+ const effectiveConnected = isConnected || (isReady && !!session);
238
+
239
+ const chatState = useChat(conversationId);
240
+ const messages = chatState.messages ?? [];
241
+ const {
242
+ isLoading,
243
+ agentTyping,
244
+ sendMessage,
245
+ notifyTyping,
246
+ loadMore,
247
+ hasMore,
248
+ conversation,
249
+ closeConversation,
250
+ reopenConversation,
251
+ } = chatState;
252
+
253
+ const [inputText, setInputText] = useState('');
254
+ const [menuVisible, setMenuVisible] = useState(false);
255
+ const [showAttachmentPicker, setShowAttachmentPicker] = useState(false);
256
+ const [pickedFile, setPickedFile] = useState<PickedFile | null>(null);
257
+ const [isIdentified, setIsIdentified] = useState(!showPreChatForm || !!user);
258
+ const [showCSAT, setShowCSAT] = useState(false);
259
+ const [keyboardHeight, setKeyboardHeight] = useState(0);
260
+ const flatListRef = useRef<FlatList<Message>>(null);
261
+ const identifiedRef = useRef(false);
262
+ const isNearBottomRef = useRef(true);
263
+
264
+ // --- Keyboard tracking (more reliable than KeyboardAvoidingView) -----------
265
+ useEffect(() => {
266
+ const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
267
+ const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
268
+
269
+ const showSub = Keyboard.addListener(showEvent, (e) => {
270
+ setKeyboardHeight(e.endCoordinates.height);
271
+ });
272
+ const hideSub = Keyboard.addListener(hideEvent, () => {
273
+ setKeyboardHeight(0);
274
+ });
275
+
276
+ return () => {
277
+ showSub.remove();
278
+ hideSub.remove();
279
+ };
280
+ }, []);
281
+
282
+ const isClosed = conversation?.status === 'closed';
283
+
284
+ const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
285
+ const { contentOffset, layoutMeasurement, contentSize } = event.nativeEvent;
286
+ const distanceFromBottom = contentSize.height - layoutMeasurement.height - contentOffset.y;
287
+ isNearBottomRef.current = distanceFromBottom < 150;
288
+ }, []);
289
+
290
+ // --- Identify user on mount ------------------------------------------------
291
+ useEffect(() => {
292
+ if (user && isReady && !identifiedRef.current) {
293
+ identifiedRef.current = true;
294
+ setIsIdentified(true);
295
+ identify(user).catch((err) => {
296
+ console.warn('[@parlr/react-native] identify failed:', err);
297
+ });
298
+ }
299
+ }, [user, isReady, identify]);
300
+
301
+ // --- Show CSAT when conversation becomes closed ---
302
+ useEffect(() => {
303
+ if (isClosed && showSatisfactionSurvey) {
304
+ setShowCSAT(true);
305
+ }
306
+ }, [isClosed, showSatisfactionSurvey]);
307
+
308
+ // --- Handlers --------------------------------------------------------------
309
+
310
+ const handleSend = useCallback(() => {
311
+ const text = inputText.trim();
312
+ if (!text) return;
313
+ setInputText('');
314
+ notifyTyping(false);
315
+ sendMessage(text);
316
+ }, [inputText, sendMessage, notifyTyping]);
317
+
318
+ const handleTextChange = useCallback(
319
+ (text: string) => {
320
+ setInputText(text);
321
+ notifyTyping(text.length > 0);
322
+ },
323
+ [notifyTyping],
324
+ );
325
+
326
+ const handleEndReached = useCallback(() => {
327
+ if (hasMore && !isLoading) {
328
+ loadMore();
329
+ }
330
+ }, [hasMore, isLoading, loadMore]);
331
+
332
+ const handleCloseConversation = useCallback(async () => {
333
+ setMenuVisible(false);
334
+ if (closeConversation) {
335
+ await closeConversation();
336
+ onConversationClosed?.();
337
+ }
338
+ }, [closeConversation, onConversationClosed]);
339
+
340
+ const handleReopenConversation = useCallback(async () => {
341
+ setMenuVisible(false);
342
+ if (reopenConversation) {
343
+ await reopenConversation();
344
+ setShowCSAT(false);
345
+ }
346
+ }, [reopenConversation]);
347
+
348
+ const handlePreChatSubmit = useCallback(
349
+ async (userData: ParlrUser) => {
350
+ await identify(userData);
351
+ setIsIdentified(true);
352
+ },
353
+ [identify],
354
+ );
355
+
356
+ const handleCSATSubmit = useCallback(
357
+ async (score: number, comment?: string) => {
358
+ if (api && conversation) {
359
+ await api.submitRating(conversation.id, score, comment);
360
+ }
361
+ setShowCSAT(false);
362
+ },
363
+ [api, conversation],
364
+ );
365
+
366
+ const handleCSATDismiss = useCallback(() => {
367
+ setShowCSAT(false);
368
+ }, []);
369
+
370
+ const handleFilePicked = useCallback((file: PickedFile) => {
371
+ setPickedFile(file);
372
+ setShowAttachmentPicker(false);
373
+ }, []);
374
+
375
+ const handleSendAttachment = useCallback(async () => {
376
+ if (!pickedFile || !api) return;
377
+
378
+ // 1. Create conversation if needed (same logic as sendMessage).
379
+ let convId = conversation?.id ?? chatState.conversation?.id;
380
+
381
+ if (!convId) {
382
+ try {
383
+ const conv = await api.createConversation();
384
+ convId = conv.id;
385
+ } catch (err) {
386
+ console.warn('[@parlr/react-native] Failed to create conversation for attachment:', err);
387
+ setPickedFile(null);
388
+ return;
389
+ }
390
+ }
391
+
392
+ // 2. Build FormData and upload.
393
+ const formData = new FormData();
394
+ formData.append('file', {
395
+ uri: pickedFile.uri,
396
+ name: pickedFile.name,
397
+ type: pickedFile.type,
398
+ } as unknown as Blob);
399
+
400
+ try {
401
+ await api.uploadAttachment(convId, formData);
402
+ } catch (err) {
403
+ console.warn('[@parlr/react-native] attachment upload failed:', err);
404
+ }
405
+ setPickedFile(null);
406
+ }, [pickedFile, api, conversation, chatState.conversation]);
407
+
408
+ // --- Render helpers --------------------------------------------------------
409
+
410
+ const renderMessage = useCallback(
411
+ ({ item, index }: ListRenderItemInfo<Message>) => (
412
+ <ChatBubble
413
+ message={item}
414
+ accentColor={accentColor}
415
+ animated={index >= messages.length - 1}
416
+ />
417
+ ),
418
+ [accentColor, messages.length],
419
+ );
420
+
421
+ const keyExtractor = useCallback(
422
+ (item: Message) => item.clientId ?? item.id,
423
+ [],
424
+ );
425
+
426
+ // --- Loading state ---------------------------------------------------------
427
+
428
+ if (!isReady) {
429
+ return (
430
+ <View
431
+ style={[
432
+ styles.loadingContainer,
433
+ { backgroundColor: theme.colors.background },
434
+ ]}
435
+ >
436
+ <ActivityIndicator size="large" color={accentColor} />
437
+ </View>
438
+ );
439
+ }
440
+
441
+ // --- Pre-chat form ---------------------------------------------------------
442
+
443
+ if (!isIdentified) {
444
+ return (
445
+ <View style={[styles.root, { backgroundColor: theme.colors.background }]}>
446
+ {showHeader && (
447
+ <View
448
+ style={[styles.header, { backgroundColor: theme.colors.surface, borderBottomColor: theme.colors.border }]}
449
+ accessibilityRole="header"
450
+ >
451
+ {onBack && (
452
+ <Pressable
453
+ onPress={onBack}
454
+ style={styles.backButton}
455
+ hitSlop={12}
456
+ accessibilityRole="button"
457
+ accessibilityLabel="Go back"
458
+ >
459
+ <Text style={[styles.backArrow, { color: theme.colors.text }]}>
460
+ {'\u2190'}
461
+ </Text>
462
+ </Pressable>
463
+ )}
464
+ <View style={styles.headerCenter}>
465
+ <Text style={[styles.headerTitle, { color: theme.colors.text }]} numberOfLines={1}>
466
+ {headerTitle ?? i18n.headerTitle}
467
+ </Text>
468
+ </View>
469
+ {onBack && <View style={styles.backButton} />}
470
+ </View>
471
+ )}
472
+ <PreChatForm
473
+ onSubmit={handlePreChatSubmit}
474
+ fields={preChatFields}
475
+ accentColor={accentColor}
476
+ locale={config.locale}
477
+ />
478
+ </View>
479
+ );
480
+ }
481
+
482
+ // --- Render ----------------------------------------------------------------
483
+
484
+ const hasMessages = messages.length > 0;
485
+
486
+ return (
487
+ <View
488
+ style={[
489
+ styles.root,
490
+ {
491
+ backgroundColor: theme.colors.background,
492
+ paddingBottom: keyboardHeight > 0 ? keyboardHeight : 0,
493
+ },
494
+ ]}
495
+ >
496
+ {/* ----- Header ----- */}
497
+ {showHeader && (
498
+ <View
499
+ style={[styles.header, { backgroundColor: theme.colors.surface, borderBottomColor: theme.colors.border }]}
500
+ accessibilityRole="header"
501
+ >
502
+ {onBack && (
503
+ <Pressable
504
+ onPress={onBack}
505
+ style={styles.backButton}
506
+ hitSlop={12}
507
+ accessibilityRole="button"
508
+ accessibilityLabel="Go back"
509
+ >
510
+ <Text style={[styles.backArrow, { color: theme.colors.text }]}>
511
+ {'\u2190'}
512
+ </Text>
513
+ </Pressable>
514
+ )}
515
+
516
+ <View style={styles.headerCenter}>
517
+ <Text
518
+ style={[styles.headerTitle, { color: theme.colors.text }]}
519
+ numberOfLines={1}
520
+ >
521
+ {headerTitle ?? i18n.headerTitle}
522
+ </Text>
523
+ <View style={styles.statusRow}>
524
+ <View
525
+ style={[
526
+ styles.statusDot,
527
+ {
528
+ backgroundColor: effectiveConnected ? theme.colors.success : theme.colors.textSecondary,
529
+ },
530
+ ]}
531
+ />
532
+ <Text style={[styles.statusText, { color: theme.colors.textSecondary }]}>
533
+ {effectiveConnected ? i18n.online : i18n.offline}
534
+ </Text>
535
+ </View>
536
+ </View>
537
+
538
+ {/* Menu button (3 dots) */}
539
+ {conversationId && (
540
+ <Pressable
541
+ onPress={() => setMenuVisible(true)}
542
+ style={styles.menuButton}
543
+ hitSlop={12}
544
+ accessibilityRole="button"
545
+ accessibilityLabel="Menu"
546
+ >
547
+ <Text style={[styles.menuDots, { color: theme.colors.text }]}>
548
+ {'\u22ee'}
549
+ </Text>
550
+ </Pressable>
551
+ )}
552
+
553
+ {/* Spacer when no menu and back button exists */}
554
+ {!conversationId && onBack && <View style={styles.backButton} />}
555
+ </View>
556
+ )}
557
+
558
+ {/* ----- Header Menu ----- */}
559
+ <HeaderMenu
560
+ visible={menuVisible}
561
+ onDismiss={() => setMenuVisible(false)}
562
+ onClose={handleCloseConversation}
563
+ onReopen={handleReopenConversation}
564
+ isClosed={isClosed}
565
+ theme={theme}
566
+ locale={config.locale}
567
+ />
568
+
569
+ {/* ----- Messages or empty state ----- */}
570
+ {hasMessages ? (
571
+ <FlatList
572
+ ref={flatListRef}
573
+ data={messages}
574
+ renderItem={renderMessage}
575
+ keyExtractor={keyExtractor}
576
+ inverted={false}
577
+ contentContainerStyle={styles.messageList}
578
+ showsVerticalScrollIndicator={false}
579
+ onEndReached={handleEndReached}
580
+ onEndReachedThreshold={0.3}
581
+ onScroll={handleScroll}
582
+ scrollEventThrottle={100}
583
+ removeClippedSubviews={true}
584
+ maxToRenderPerBatch={15}
585
+ windowSize={10}
586
+ ListHeaderComponent={
587
+ isLoading ? (
588
+ <ActivityIndicator
589
+ size="small"
590
+ color={accentColor}
591
+ style={styles.loadingMore}
592
+ />
593
+ ) : null
594
+ }
595
+ ListFooterComponent={
596
+ <>
597
+ {agentTyping && <TypingIndicator accentColor={accentColor} />}
598
+ {showCSAT && (
599
+ <SatisfactionSurvey
600
+ onSubmit={handleCSATSubmit}
601
+ onDismiss={handleCSATDismiss}
602
+ accentColor={accentColor}
603
+ locale={config.locale}
604
+ />
605
+ )}
606
+ </>
607
+ }
608
+ onContentSizeChange={() => {
609
+ if (isNearBottomRef.current) {
610
+ flatListRef.current?.scrollToEnd({ animated: true });
611
+ }
612
+ }}
613
+ />
614
+ ) : (
615
+ <EmptyState
616
+ title={emptyStateTitle}
617
+ description={emptyStateDescription}
618
+ accentColor={accentColor}
619
+ locale={config.locale}
620
+ />
621
+ )}
622
+
623
+ {/* ----- Closed banner ----- */}
624
+ {isClosed && !showCSAT && (
625
+ <View style={[styles.closedBanner, { backgroundColor: theme.colors.surface }]}>
626
+ <Text style={[styles.closedText, { color: theme.colors.textSecondary }]}>
627
+ {i18n.conversationClosed}
628
+ </Text>
629
+ </View>
630
+ )}
631
+
632
+ {/* ----- Attachment preview ----- */}
633
+ {pickedFile && (
634
+ <AttachmentPreview
635
+ file={pickedFile}
636
+ onRemove={() => setPickedFile(null)}
637
+ onSend={handleSendAttachment}
638
+ accentColor={accentColor}
639
+ locale={config.locale}
640
+ />
641
+ )}
642
+
643
+ {/* ----- Attachment picker ----- */}
644
+ {showAttachmentPicker && (
645
+ <AttachmentPicker
646
+ onFilePicked={handleFilePicked}
647
+ onDismiss={() => setShowAttachmentPicker(false)}
648
+ accentColor={accentColor}
649
+ locale={config.locale}
650
+ />
651
+ )}
652
+
653
+ {/* ----- Input bar ----- */}
654
+ {!isClosed && !pickedFile && !showAttachmentPicker && (
655
+ <View
656
+ style={[
657
+ styles.inputBar,
658
+ {
659
+ backgroundColor: theme.colors.surface,
660
+ borderTopColor: theme.colors.border,
661
+ paddingBottom: keyboardHeight > 0 ? 10 : 10 + safeAreaBottom,
662
+ },
663
+ ]}
664
+ >
665
+ {/* Attachment button */}
666
+ <Pressable
667
+ onPress={() => setShowAttachmentPicker(true)}
668
+ style={[styles.attachButton, { backgroundColor: theme.colors.surface }]}
669
+ hitSlop={8}
670
+ accessibilityRole="button"
671
+ accessibilityLabel="Add attachment"
672
+ >
673
+ <Text style={[styles.attachIcon, { color: theme.colors.textSecondary }]}>+</Text>
674
+ </Pressable>
675
+
676
+ <TextInput
677
+ style={[
678
+ styles.textInput,
679
+ {
680
+ backgroundColor: theme.colors.surface,
681
+ borderColor: theme.colors.border,
682
+ color: theme.colors.text,
683
+ },
684
+ ]}
685
+ value={inputText}
686
+ onChangeText={handleTextChange}
687
+ placeholder={placeholder ?? i18n.placeholder}
688
+ placeholderTextColor={theme.colors.textSecondary}
689
+ multiline
690
+ maxLength={4000}
691
+ returnKeyType="default"
692
+ blurOnSubmit={false}
693
+ accessibilityLabel="Message input"
694
+ accessibilityHint="Type a message to send"
695
+ />
696
+
697
+ <SendButton
698
+ onPress={handleSend}
699
+ disabled={inputText.trim().length === 0}
700
+ accentColor={accentColor}
701
+ theme={theme}
702
+ />
703
+ </View>
704
+ )}
705
+ </View>
706
+ );
707
+ }
708
+
709
+ // ---------------------------------------------------------------------------
710
+ // Styles
711
+ // ---------------------------------------------------------------------------
712
+
713
+ const styles = StyleSheet.create({
714
+ root: {
715
+ flex: 1,
716
+ },
717
+ loadingContainer: {
718
+ flex: 1,
719
+ justifyContent: 'center',
720
+ alignItems: 'center',
721
+ },
722
+
723
+ // Header
724
+ header: {
725
+ flexDirection: 'row',
726
+ alignItems: 'center',
727
+ paddingHorizontal: 16,
728
+ paddingTop: 12,
729
+ paddingBottom: 12,
730
+ borderBottomWidth: StyleSheet.hairlineWidth,
731
+ },
732
+ backButton: {
733
+ width: 40,
734
+ height: 40,
735
+ justifyContent: 'center',
736
+ alignItems: 'center',
737
+ },
738
+ backArrow: {
739
+ fontSize: 22,
740
+ fontWeight: '500',
741
+ },
742
+ headerCenter: {
743
+ flex: 1,
744
+ alignItems: 'center',
745
+ },
746
+ headerTitle: {
747
+ fontSize: 17,
748
+ fontWeight: '600',
749
+ },
750
+ statusRow: {
751
+ flexDirection: 'row',
752
+ alignItems: 'center',
753
+ marginTop: 2,
754
+ },
755
+ statusDot: {
756
+ width: 7,
757
+ height: 7,
758
+ borderRadius: 3.5,
759
+ marginRight: 5,
760
+ },
761
+ statusText: {
762
+ fontSize: 12,
763
+ },
764
+ menuButton: {
765
+ width: 40,
766
+ height: 40,
767
+ justifyContent: 'center',
768
+ alignItems: 'center',
769
+ },
770
+ menuDots: {
771
+ fontSize: 20,
772
+ fontWeight: '700',
773
+ },
774
+
775
+ // Header menu
776
+ menuBackdrop: {
777
+ flex: 1,
778
+ backgroundColor: 'rgba(0, 0, 0, 0.3)',
779
+ justifyContent: 'flex-start',
780
+ alignItems: 'flex-end',
781
+ paddingTop: Platform.OS === 'ios' ? 110 : 70,
782
+ paddingRight: 16,
783
+ },
784
+ menuContainer: {
785
+ borderRadius: 10,
786
+ paddingVertical: 4,
787
+ minWidth: 200,
788
+ shadowColor: '#000',
789
+ shadowOffset: { width: 0, height: 4 },
790
+ shadowOpacity: 0.15,
791
+ shadowRadius: 12,
792
+ elevation: 8,
793
+ },
794
+ menuItem: {
795
+ paddingHorizontal: 16,
796
+ paddingVertical: 12,
797
+ },
798
+ menuItemText: {
799
+ fontSize: 15,
800
+ },
801
+
802
+ // Message list
803
+ messageList: {
804
+ paddingVertical: 12,
805
+ },
806
+ loadingMore: {
807
+ marginVertical: 8,
808
+ },
809
+
810
+ // Closed banner
811
+ closedBanner: {
812
+ paddingVertical: 12,
813
+ paddingHorizontal: 16,
814
+ alignItems: 'center',
815
+ },
816
+ closedText: {
817
+ fontSize: 13,
818
+ },
819
+
820
+ // Input bar
821
+ inputBar: {
822
+ flexDirection: 'row',
823
+ alignItems: 'flex-end',
824
+ paddingHorizontal: 12,
825
+ paddingVertical: 10,
826
+ borderTopWidth: StyleSheet.hairlineWidth,
827
+ gap: 8,
828
+ },
829
+ attachButton: {
830
+ width: 38,
831
+ height: 38,
832
+ borderRadius: 19,
833
+ justifyContent: 'center',
834
+ alignItems: 'center',
835
+ marginBottom: 1,
836
+ },
837
+ attachIcon: {
838
+ fontSize: 22,
839
+ fontWeight: '300',
840
+ },
841
+ textInput: {
842
+ flex: 1,
843
+ fontSize: 15,
844
+ lineHeight: 20,
845
+ maxHeight: 100,
846
+ paddingHorizontal: 16,
847
+ paddingVertical: 10,
848
+ borderRadius: 22,
849
+ borderWidth: 1,
850
+ },
851
+ sendButton: {
852
+ width: 38,
853
+ height: 38,
854
+ borderRadius: 19,
855
+ justifyContent: 'center',
856
+ alignItems: 'center',
857
+ marginBottom: 1,
858
+ },
859
+ sendIcon: {
860
+ fontSize: 18,
861
+ fontWeight: '700',
862
+ },
863
+ });