@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,534 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Parlr React Native SDK - useChat Hook
3
+ // ---------------------------------------------------------------------------
4
+ //
5
+ // Manages real-time chat state for a single conversation. Handles:
6
+ // - Message fetching and pagination
7
+ // - Optimistic message sending with crypto.randomUUID() idempotency keys
8
+ // - WebSocket subscriptions (new messages, typing indicators)
9
+ // - Typing notification debouncing
10
+ // - Message retry for failed sends
11
+ // ---------------------------------------------------------------------------
12
+
13
+ import { useCallback, useContext, useEffect, useRef, useState } from 'react';
14
+ import { ParlrError } from '../core/errors';
15
+ import { ParlrContext } from '../provider/ParlrContext';
16
+ import type {
17
+ Conversation,
18
+ Message,
19
+ WsConversationUpdatedPayload,
20
+ WsNewMessagePayload,
21
+ WsTypingPayload,
22
+ } from '../core/types';
23
+
24
+ const MAX_SEND_RETRIES = 3;
25
+ const RETRY_BASE_DELAY_MS = 1_000;
26
+
27
+ export interface UseChatReturn {
28
+ /** Ordered message list (newest last). */
29
+ messages: Message[];
30
+ /** True while the initial message page is loading. */
31
+ isLoading: boolean;
32
+ /** True if a load/send error occurred. */
33
+ hasError: boolean;
34
+ /** The active conversation (may be null until first message). */
35
+ conversation: Conversation | null;
36
+ /** Whether an agent is currently typing. */
37
+ agentTyping: boolean;
38
+ /** Send a text message. Creates the conversation on first call if needed. */
39
+ sendMessage: (content: string) => Promise<void>;
40
+ /** Retry sending a failed message by its clientId. */
41
+ retryMessage: (clientId: string) => Promise<void>;
42
+ /** Notify the server that the contact started/stopped typing. */
43
+ notifyTyping: (isTyping: boolean) => void;
44
+ /** Load older messages (pagination). */
45
+ loadMore: () => Promise<void>;
46
+ /** Whether there are older messages to load. */
47
+ hasMore: boolean;
48
+ /** Close the current conversation. */
49
+ closeConversation: () => Promise<void>;
50
+ /** Reopen the current conversation. */
51
+ reopenConversation: () => Promise<void>;
52
+ }
53
+
54
+ /** Generate a UUID v4 using crypto.randomUUID (available in Hermes/RN). */
55
+ function generateId(): string {
56
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
57
+ return crypto.randomUUID();
58
+ }
59
+ // Fallback using crypto.getRandomValues (available in Hermes).
60
+ if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
61
+ const bytes = new Uint8Array(16);
62
+ crypto.getRandomValues(bytes);
63
+ // Set version (4) and variant (RFC4122).
64
+ bytes[6] = (bytes[6]! & 0x0f) | 0x40;
65
+ bytes[8] = (bytes[8]! & 0x3f) | 0x80;
66
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
67
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
68
+ }
69
+ // Last-resort fallback for environments without crypto.
70
+ return `${Date.now().toString(36)}-${Array.from({ length: 12 }, () => ((Math.random() * 36) | 0).toString(36)).join('')}`;
71
+ }
72
+
73
+ /**
74
+ * Chat hook — manages messages and typing for one conversation.
75
+ *
76
+ * @param conversationId - Existing conversation ID, or undefined to create
77
+ * one on first `sendMessage`.
78
+ */
79
+ export function useChat(conversationId?: string): UseChatReturn {
80
+ const { api, ws, config, session, refreshConversations } =
81
+ useContext(ParlrContext);
82
+
83
+ const [messages, setMessages] = useState<Message[]>([]);
84
+ const [conversation, setConversation] = useState<Conversation | null>(null);
85
+ const [isLoading, setIsLoading] = useState(false);
86
+ const [hasError, setHasError] = useState(false);
87
+ const [agentTyping, setAgentTyping] = useState(false);
88
+ const [page, setPage] = useState(1);
89
+ const [hasMore, setHasMore] = useState(true);
90
+
91
+ // Resolved conversation ID: uses the prop when provided, otherwise
92
+ // auto-resolved from the API by picking the most recent open conversation.
93
+ const [resolvedConvId, setResolvedConvId] = useState<string | undefined>(conversationId);
94
+
95
+ // Mutable ref for the active conversation id (may be set lazily).
96
+ const activeConvId = useRef<string | undefined>(resolvedConvId);
97
+ activeConvId.current = resolvedConvId ?? activeConvId.current;
98
+
99
+ // Track retry attempts per clientId.
100
+ const retryCountRef = useRef<Map<string, number>>(new Map());
101
+
102
+ // Agent typing timeout — auto-clear after 5 s of silence.
103
+ const typingTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
104
+
105
+ // Contact typing debounce — avoid spamming the server.
106
+ const lastTypingNotify = useRef<number>(0);
107
+
108
+ // --- Keep resolved ID in sync with the prop (prop always wins) -------------
109
+
110
+ useEffect(() => {
111
+ if (conversationId) {
112
+ setResolvedConvId(conversationId);
113
+ }
114
+ }, [conversationId]);
115
+
116
+ // --- Auto-resolve: fetch the most recent open conversation from the API ---
117
+ // This runs only when no conversationId prop is provided, so returning users
118
+ // see their message history immediately without having to send a new message.
119
+
120
+ useEffect(() => {
121
+ console.log('[@parlr/react-native][useChat] auto-resolve check', {
122
+ conversationId,
123
+ resolvedConvId,
124
+ hasApi: !!api,
125
+ hasSession: !!session,
126
+ });
127
+
128
+ if (conversationId || resolvedConvId || !api || !session) return;
129
+
130
+ let cancelled = false;
131
+
132
+ async function resolve() {
133
+ try {
134
+ console.log('[@parlr/react-native][useChat] fetching conversations for auto-resolve...');
135
+ const res = await api!.listConversations();
136
+ console.log('[@parlr/react-native][useChat] listConversations result', {
137
+ count: res.data.length,
138
+ conversations: res.data.map((c: Conversation) => ({ id: c.id, status: c.status, lastMessageAt: c.lastMessageAt })),
139
+ });
140
+ if (cancelled) return;
141
+
142
+ const open = res.data
143
+ .filter((c: Conversation) => c.status !== 'closed')
144
+ .sort((a: Conversation, b: Conversation) => {
145
+ const aT = a.lastMessageAt ? new Date(a.lastMessageAt).getTime() : 0;
146
+ const bT = b.lastMessageAt ? new Date(b.lastMessageAt).getTime() : 0;
147
+ return bT - aT;
148
+ });
149
+
150
+ console.log('[@parlr/react-native][useChat] open conversations', {
151
+ count: open.length,
152
+ picked: open[0]?.id ?? 'none',
153
+ });
154
+
155
+ if (open.length > 0 && open[0]) {
156
+ setResolvedConvId(open[0].id);
157
+ }
158
+ } catch (err) {
159
+ console.warn('[@parlr/react-native][useChat] auto-resolve failed', err);
160
+ }
161
+ }
162
+
163
+ resolve();
164
+
165
+ return () => { cancelled = true; };
166
+ }, [conversationId, resolvedConvId, api, session]);
167
+
168
+ // --- Fetch initial messages ------------------------------------------------
169
+
170
+ useEffect(() => {
171
+ console.log('[@parlr/react-native][useChat] fetch effect', {
172
+ hasApi: !!api,
173
+ activeConvId: activeConvId.current,
174
+ hasSession: !!session,
175
+ resolvedConvId,
176
+ });
177
+ if (!api || !activeConvId.current || !session) return;
178
+
179
+ let cancelled = false;
180
+
181
+ async function fetchMessages() {
182
+ setIsLoading(true);
183
+ setHasError(false);
184
+
185
+ try {
186
+ const res = await api!.getMessages(activeConvId.current!, 1);
187
+ if (!cancelled) {
188
+ setMessages(res.data);
189
+ setHasMore(res.meta.hasMore);
190
+ setPage(1);
191
+ }
192
+ } catch (err) {
193
+ if (config.debug) {
194
+ console.warn('[@parlr/react-native] Failed to load messages:', err);
195
+ }
196
+ config.onError?.(err instanceof ParlrError ? err : new ParlrError(err instanceof Error ? err.message : String(err)));
197
+ if (!cancelled) setHasError(true);
198
+ } finally {
199
+ if (!cancelled) setIsLoading(false);
200
+ }
201
+ }
202
+
203
+ fetchMessages();
204
+
205
+ return () => {
206
+ cancelled = true;
207
+ };
208
+ }, [api, session, config, resolvedConvId]);
209
+
210
+ // --- WebSocket subscriptions -----------------------------------------------
211
+
212
+ useEffect(() => {
213
+ if (!ws) return;
214
+
215
+ const offMessage = ws.on('new_message', (payload: WsNewMessagePayload) => {
216
+ // Only append if it belongs to our active conversation.
217
+ if (payload.conversationId !== activeConvId.current) return;
218
+
219
+ const msg: Message = {
220
+ id: payload.id,
221
+ conversationId: payload.conversationId,
222
+ senderType: payload.senderType,
223
+ senderName: payload.senderName,
224
+ senderAvatarUrl: payload.senderAvatarUrl,
225
+ content: payload.content,
226
+ createdAt: payload.createdAt,
227
+ clientId: payload.clientId,
228
+ status: 'sent',
229
+ };
230
+
231
+ setMessages((prev: Message[]) => {
232
+ // Deduplicate: replace optimistic message with server-confirmed one.
233
+ const withoutOptimistic = prev.filter(
234
+ (m: Message) => !(m.clientId && m.clientId === msg.clientId),
235
+ );
236
+ return [...withoutOptimistic, msg];
237
+ });
238
+
239
+ // Clear agent typing when a real message arrives.
240
+ if (msg.senderType === 'agent') {
241
+ setAgentTyping(false);
242
+ }
243
+ });
244
+
245
+ const offTypingStart = ws.on('typing_start', (payload: WsTypingPayload) => {
246
+ if (payload.conversationId !== activeConvId.current) return;
247
+ setAgentTyping(true);
248
+
249
+ // Auto-clear after 5 s of no further typing event.
250
+ if (typingTimeout.current) clearTimeout(typingTimeout.current);
251
+ typingTimeout.current = setTimeout(() => setAgentTyping(false), 5_000);
252
+ });
253
+
254
+ const offTypingStop = ws.on('typing_stop', (payload: WsTypingPayload) => {
255
+ if (payload.conversationId !== activeConvId.current) return;
256
+ setAgentTyping(false);
257
+ if (typingTimeout.current) clearTimeout(typingTimeout.current);
258
+ });
259
+
260
+ const offConvUpdated = ws.on('conversation_updated', (payload: WsConversationUpdatedPayload) => {
261
+ if (payload.conversationId !== activeConvId.current) return;
262
+ setConversation((prev) => {
263
+ if (!prev) return prev;
264
+ return {
265
+ ...prev,
266
+ ...(payload.status !== undefined ? { status: payload.status } : {}),
267
+ ...(payload.assignee !== undefined ? { assignee: payload.assignee } : {}),
268
+ };
269
+ });
270
+ });
271
+
272
+ return () => {
273
+ offMessage();
274
+ offTypingStart();
275
+ offTypingStop();
276
+ offConvUpdated();
277
+ };
278
+ }, [ws]);
279
+
280
+ // --- Polling fallback when WebSocket is not connected ----------------------
281
+
282
+ useEffect(() => {
283
+ if (!api || !session) return;
284
+
285
+ const POLL_INTERVAL = 5_000; // 5 seconds
286
+
287
+ const poll = async () => {
288
+ // Skip this tick if WebSocket is connected (real-time push handles it).
289
+ if (ws?.connected) return;
290
+
291
+ const convId = activeConvId.current;
292
+ if (!convId) return;
293
+
294
+ try {
295
+ const res = await api.getMessages(convId, 1);
296
+ setMessages(res.data);
297
+ } catch {
298
+ // Silently ignore polling errors.
299
+ }
300
+ };
301
+
302
+ const interval = setInterval(poll, POLL_INTERVAL);
303
+ return () => clearInterval(interval);
304
+ }, [api, session, ws]);
305
+
306
+ // --- Internal send logic ---------------------------------------------------
307
+
308
+ const doSend = useCallback(
309
+ async (convId: string, content: string, clientId: string): Promise<void> => {
310
+ /* istanbul ignore next -- defensive guard: callers already check api */
311
+ if (!api) return;
312
+
313
+ try {
314
+ ws?.send({
315
+ type: 'send_message',
316
+ payload: { conversationId: convId, content, clientId },
317
+ });
318
+
319
+ const confirmed = await api.sendMessage(convId, content, clientId);
320
+
321
+ setMessages((prev: Message[]) =>
322
+ prev.map((m: Message) =>
323
+ m.clientId === clientId ? { ...confirmed, status: 'sent' as const } : m,
324
+ ),
325
+ );
326
+
327
+ // Clean up retry tracking.
328
+ retryCountRef.current.delete(clientId);
329
+ } catch (err) {
330
+ console.error('[@parlr/react-native] Failed to send message:', err);
331
+ config.onError?.(err instanceof ParlrError ? err : new ParlrError(err instanceof Error ? err.message : String(err)));
332
+ setMessages((prev: Message[]) =>
333
+ prev.map((m: Message) =>
334
+ m.clientId === clientId ? { ...m, status: 'failed' as const } : m,
335
+ ),
336
+ );
337
+ }
338
+ },
339
+ [api, ws, config],
340
+ );
341
+
342
+ // --- Send message ----------------------------------------------------------
343
+
344
+ const sendMessage = useCallback(
345
+ async (content: string) => {
346
+ if (!api) return;
347
+
348
+ const trimmed = content.trim();
349
+ if (!trimmed) return;
350
+
351
+ // 1. Create conversation if needed.
352
+ let convId = activeConvId.current;
353
+
354
+ if (!convId) {
355
+ try {
356
+ const conv = await api.createConversation(trimmed);
357
+ convId = conv.id;
358
+ activeConvId.current = convId;
359
+ setResolvedConvId(convId);
360
+ setConversation(conv);
361
+ refreshConversations();
362
+
363
+ // The server created the conversation AND the first message;
364
+ // fetch messages so we have the server-assigned IDs.
365
+ const res = await api.getMessages(convId);
366
+ setMessages(res.data);
367
+ return;
368
+ } catch (err) {
369
+ console.error(
370
+ '[@parlr/react-native] Failed to create conversation:',
371
+ err,
372
+ );
373
+ config.onError?.(err instanceof ParlrError ? err : new ParlrError(err instanceof Error ? err.message : String(err)));
374
+ setHasError(true);
375
+ return;
376
+ }
377
+ }
378
+
379
+ // 2. Optimistic insert.
380
+ const clientId = generateId();
381
+ const optimistic: Message = {
382
+ id: clientId,
383
+ conversationId: convId,
384
+ senderType: 'contact',
385
+ content: trimmed,
386
+ createdAt: new Date().toISOString(),
387
+ clientId,
388
+ status: 'sending',
389
+ };
390
+
391
+ setMessages((prev: Message[]) => [...prev, optimistic]);
392
+
393
+ // 3. Send via REST (for reliability) and via WS (for speed).
394
+ await doSend(convId, trimmed, clientId);
395
+ },
396
+ [api, config, refreshConversations, doSend],
397
+ );
398
+
399
+ // --- Retry failed message --------------------------------------------------
400
+
401
+ const retryMessage = useCallback(
402
+ async (clientId: string) => {
403
+ const msg = messages.find(
404
+ (m: Message) => m.clientId === clientId && m.status === 'failed',
405
+ );
406
+ if (!msg) return;
407
+
408
+ const currentRetries = retryCountRef.current.get(clientId) ?? 0;
409
+ if (currentRetries >= MAX_SEND_RETRIES) {
410
+ console.warn(
411
+ `[@parlr/react-native] Max retries (${MAX_SEND_RETRIES}) reached for message ${clientId}`,
412
+ );
413
+ return;
414
+ }
415
+
416
+ retryCountRef.current.set(clientId, currentRetries + 1);
417
+
418
+ // Mark as sending again.
419
+ setMessages((prev: Message[]) =>
420
+ prev.map((m: Message) =>
421
+ m.clientId === clientId ? { ...m, status: 'sending' as const } : m,
422
+ ),
423
+ );
424
+
425
+ // Backoff before retry.
426
+ const backoff = RETRY_BASE_DELAY_MS * Math.pow(2, currentRetries);
427
+ await new Promise((resolve) => setTimeout(resolve, backoff));
428
+
429
+ await doSend(msg.conversationId, msg.content, clientId);
430
+ },
431
+ [messages, doSend],
432
+ );
433
+
434
+ // --- Typing notification ---------------------------------------------------
435
+
436
+ const notifyTyping = useCallback(
437
+ (isTyping: boolean) => {
438
+ const convId = activeConvId.current;
439
+ if (!convId) return;
440
+
441
+ // Debounce: only send typing_start at most once every 3 s.
442
+ if (isTyping) {
443
+ const now = Date.now();
444
+ if (now - lastTypingNotify.current < 3_000) return;
445
+ lastTypingNotify.current = now;
446
+ }
447
+
448
+ ws?.send({
449
+ type: isTyping ? 'typing_start' : 'typing_stop',
450
+ payload: { conversationId: convId },
451
+ });
452
+
453
+ // Also notify via REST for reliability.
454
+ api?.sendTypingIndicator(convId, isTyping).catch(() => {
455
+ // Best-effort — swallow errors on typing indicators.
456
+ });
457
+ },
458
+ [api, ws],
459
+ );
460
+
461
+ // --- Load more (pagination) ------------------------------------------------
462
+
463
+ const loadMore = useCallback(async () => {
464
+ const convId = activeConvId.current;
465
+ if (!api || !convId || !hasMore || isLoading) return;
466
+
467
+ const nextPage = page + 1;
468
+ try {
469
+ const res = await api.getMessages(convId, nextPage);
470
+ setMessages((prev: Message[]) => [...res.data, ...prev]);
471
+ setPage(nextPage);
472
+ setHasMore(res.meta.hasMore);
473
+ } catch (err) {
474
+ if (config.debug) {
475
+ console.warn('[@parlr/react-native] Failed to load more:', err);
476
+ }
477
+ }
478
+ }, [api, hasMore, isLoading, page, config.debug]);
479
+
480
+ // --- Close / Reopen --------------------------------------------------------
481
+
482
+ const closeConversation = useCallback(async () => {
483
+ const convId = activeConvId.current;
484
+ if (!api || !convId) return;
485
+
486
+ try {
487
+ const updated = await api.closeConversation(convId);
488
+ setConversation(updated);
489
+ } catch (err) {
490
+ if (config.debug) {
491
+ console.warn('[@parlr/react-native] Failed to close conversation:', err);
492
+ }
493
+ config.onError?.(err instanceof ParlrError ? err : new ParlrError(err instanceof Error ? err.message : String(err)));
494
+ }
495
+ }, [api, config]);
496
+
497
+ const reopenConversation = useCallback(async () => {
498
+ const convId = activeConvId.current;
499
+ if (!api || !convId) return;
500
+
501
+ try {
502
+ const updated = await api.reopenConversation(convId);
503
+ setConversation(updated);
504
+ } catch (err) {
505
+ if (config.debug) {
506
+ console.warn('[@parlr/react-native] Failed to reopen conversation:', err);
507
+ }
508
+ config.onError?.(err instanceof ParlrError ? err : new ParlrError(err instanceof Error ? err.message : String(err)));
509
+ }
510
+ }, [api, config]);
511
+
512
+ // --- Cleanup ---------------------------------------------------------------
513
+
514
+ useEffect(() => {
515
+ return () => {
516
+ if (typingTimeout.current) clearTimeout(typingTimeout.current);
517
+ };
518
+ }, []);
519
+
520
+ return {
521
+ messages,
522
+ isLoading,
523
+ hasError,
524
+ conversation,
525
+ agentTyping,
526
+ sendMessage,
527
+ retryMessage,
528
+ notifyTyping,
529
+ loadMore,
530
+ hasMore,
531
+ closeConversation,
532
+ reopenConversation,
533
+ };
534
+ }
@@ -0,0 +1,43 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Parlr React Native SDK - useParlr Hook
3
+ // ---------------------------------------------------------------------------
4
+ //
5
+ // High-level hook for programmatic access to the SDK state and actions.
6
+ // Must be used within a <ParlrProvider>.
7
+ // ---------------------------------------------------------------------------
8
+
9
+ import { useContext } from 'react';
10
+ import { ParlrContext } from '../provider/ParlrContext';
11
+ import type { ParlrContextValue } from '../core/types';
12
+
13
+ /**
14
+ * Access the Parlr SDK from any component within a `<ParlrProvider>`.
15
+ *
16
+ * @example
17
+ * ```tsx
18
+ * const { isReady, identify, conversations, unreadCount } = useParlr();
19
+ * ```
20
+ */
21
+ export function useParlr(): ParlrContextValue {
22
+ const ctx = useContext(ParlrContext);
23
+
24
+ if (!ctx.config.workspaceId) {
25
+ throw new Error(
26
+ '[@parlr/react-native] useParlr() must be used within a <ParlrProvider>. ' +
27
+ 'Wrap your app root with <ParlrProvider workspaceId="ws_xxx">.',
28
+ );
29
+ }
30
+
31
+ // Return only the public-facing subset (omitting api/ws internals).
32
+ return {
33
+ config: ctx.config,
34
+ session: ctx.session,
35
+ isReady: ctx.isReady,
36
+ isConnected: ctx.isConnected,
37
+ conversations: ctx.conversations,
38
+ unreadCount: ctx.unreadCount,
39
+ identify: ctx.identify,
40
+ refreshConversations: ctx.refreshConversations,
41
+ theme: ctx.theme,
42
+ };
43
+ }
package/src/index.ts ADDED
@@ -0,0 +1,98 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @parlr/react-native - Official Parlr Live Chat SDK for React Native
3
+ // ---------------------------------------------------------------------------
4
+ //
5
+ // Public API surface. Everything exported here is part of the stable contract.
6
+ //
7
+ // Usage:
8
+ //
9
+ // import { ParlrProvider, ParlrChat, useParlr, useChat } from '@parlr/react-native';
10
+ //
11
+ // // 1. Wrap your app
12
+ // <ParlrProvider workspaceId="ws_xxx">
13
+ // <App />
14
+ // </ParlrProvider>
15
+ //
16
+ // // 2. Drop the chat screen anywhere
17
+ // <ParlrChat user={{ email: 'alice@acme.com', name: 'Alice' }} onBack={goBack} />
18
+ //
19
+ // // 3. Or use hooks for programmatic access
20
+ // const { isReady, identify, unreadCount } = useParlr();
21
+ // const { messages, sendMessage, agentTyping } = useChat(conversationId);
22
+ //
23
+ // ---------------------------------------------------------------------------
24
+
25
+ // Components
26
+ export { ParlrProvider } from './provider/ParlrProvider';
27
+ export { ParlrChat } from './components/ParlrChat';
28
+ export { ParlrConversationList } from './components/ParlrConversationList';
29
+ export { ChatBubble } from './components/ChatBubble';
30
+ export { TypingIndicator } from './components/TypingIndicator';
31
+ export { EmptyState } from './components/EmptyState';
32
+ export { PreChatForm } from './components/PreChatForm';
33
+ export { SatisfactionSurvey } from './components/SatisfactionSurvey';
34
+ export { AttachmentPicker } from './components/AttachmentPicker';
35
+ export { AttachmentPreview } from './components/AttachmentPreview';
36
+ export { RichMessage } from './components/RichMessage';
37
+
38
+ // Hooks
39
+ export { useParlr } from './hooks/useParlr';
40
+ export { useChat } from './hooks/useChat';
41
+
42
+ // Errors
43
+ export {
44
+ ParlrError,
45
+ ParlrNetworkError,
46
+ ParlrAuthError,
47
+ ParlrValidationError,
48
+ ParlrConnectionError,
49
+ } from './core/errors';
50
+
51
+ // Types
52
+ export type {
53
+ Attachment,
54
+ MessageContentType,
55
+ RatingRequest,
56
+ ParlrConfig,
57
+ ParlrUser,
58
+ Conversation,
59
+ Message,
60
+ Session,
61
+ ResolvedConfig,
62
+ ParlrContextValue,
63
+ WsEventMap,
64
+ WsNewMessagePayload,
65
+ WsTypingPayload,
66
+ WsConversationUpdatedPayload,
67
+ } from './core/types';
68
+
69
+ // Offline queue
70
+ export {
71
+ queueMessage,
72
+ getQueuedMessages,
73
+ removeFromQueue,
74
+ flushQueue,
75
+ } from './core/offlineQueue';
76
+ export type { QueuedMessage } from './core/offlineQueue';
77
+
78
+ // Push notifications
79
+ export { registerPushToken, unregisterPushToken } from './core/pushNotifications';
80
+
81
+ // Theme
82
+ export {
83
+ defaultLightTheme,
84
+ defaultDarkTheme,
85
+ mergeTheme,
86
+ } from './core/theme';
87
+ export type { ParlrTheme } from './core/theme';
88
+
89
+ // Props types (for consumers that need to type-check wrappers)
90
+ export type { ParlrProviderProps } from './provider/ParlrProvider';
91
+ export type { ParlrChatProps } from './components/ParlrChat';
92
+ export type { ParlrConversationListProps } from './components/ParlrConversationList';
93
+ export type { PreChatFormProps } from './components/PreChatForm';
94
+ export type { SatisfactionSurveyProps } from './components/SatisfactionSurvey';
95
+ export type { AttachmentPickerProps, PickedFile } from './components/AttachmentPicker';
96
+ export type { AttachmentPreviewProps } from './components/AttachmentPreview';
97
+ export type { RichMessageProps, RichContent, RichCard, RichAction } from './components/RichMessage';
98
+ export type { UseChatReturn } from './hooks/useChat';