@repzo/widget-react-native 1.0.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 (448) hide show
  1. package/README.md +482 -0
  2. package/lib/commonjs/agent-ui-blocks.js +159 -0
  3. package/lib/commonjs/agent-ui-blocks.js.map +1 -0
  4. package/lib/commonjs/api/client.js +267 -0
  5. package/lib/commonjs/api/client.js.map +1 -0
  6. package/lib/commonjs/api/realtime.js +193 -0
  7. package/lib/commonjs/api/realtime.js.map +1 -0
  8. package/lib/commonjs/api/sse-parser.js +58 -0
  9. package/lib/commonjs/api/sse-parser.js.map +1 -0
  10. package/lib/commonjs/controller.js +23 -0
  11. package/lib/commonjs/controller.js.map +1 -0
  12. package/lib/commonjs/device-id.js +75 -0
  13. package/lib/commonjs/device-id.js.map +1 -0
  14. package/lib/commonjs/events.js +49 -0
  15. package/lib/commonjs/events.js.map +1 -0
  16. package/lib/commonjs/i18n.js +106 -0
  17. package/lib/commonjs/i18n.js.map +1 -0
  18. package/lib/commonjs/index.js +98 -0
  19. package/lib/commonjs/index.js.map +1 -0
  20. package/lib/commonjs/locales.js +85 -0
  21. package/lib/commonjs/locales.js.map +1 -0
  22. package/lib/commonjs/package.json +1 -0
  23. package/lib/commonjs/push/push-notifications.js +208 -0
  24. package/lib/commonjs/push/push-notifications.js.map +1 -0
  25. package/lib/commonjs/state/use-widget-state.js +23 -0
  26. package/lib/commonjs/state/use-widget-state.js.map +1 -0
  27. package/lib/commonjs/state/widget-store.js +1682 -0
  28. package/lib/commonjs/state/widget-store.js.map +1 -0
  29. package/lib/commonjs/storage.js +147 -0
  30. package/lib/commonjs/storage.js.map +1 -0
  31. package/lib/commonjs/types.js +44 -0
  32. package/lib/commonjs/types.js.map +1 -0
  33. package/lib/commonjs/ui/WidgetProvider.js +404 -0
  34. package/lib/commonjs/ui/WidgetProvider.js.map +1 -0
  35. package/lib/commonjs/ui/components/AgentUiBlocks.js +864 -0
  36. package/lib/commonjs/ui/components/AgentUiBlocks.js.map +1 -0
  37. package/lib/commonjs/ui/components/Avatar.js +76 -0
  38. package/lib/commonjs/ui/components/Avatar.js.map +1 -0
  39. package/lib/commonjs/ui/components/ConversationRow.js +155 -0
  40. package/lib/commonjs/ui/components/ConversationRow.js.map +1 -0
  41. package/lib/commonjs/ui/components/ErrorBoundary.js +44 -0
  42. package/lib/commonjs/ui/components/ErrorBoundary.js.map +1 -0
  43. package/lib/commonjs/ui/components/Gradient.js +168 -0
  44. package/lib/commonjs/ui/components/Gradient.js.map +1 -0
  45. package/lib/commonjs/ui/components/Launcher.js +125 -0
  46. package/lib/commonjs/ui/components/Launcher.js.map +1 -0
  47. package/lib/commonjs/ui/components/Markdown.js +257 -0
  48. package/lib/commonjs/ui/components/Markdown.js.map +1 -0
  49. package/lib/commonjs/ui/components/OutboundMessageCard.js +157 -0
  50. package/lib/commonjs/ui/components/OutboundMessageCard.js.map +1 -0
  51. package/lib/commonjs/ui/components/WidgetPanel.js +131 -0
  52. package/lib/commonjs/ui/components/WidgetPanel.js.map +1 -0
  53. package/lib/commonjs/ui/components/icons.js +383 -0
  54. package/lib/commonjs/ui/components/icons.js.map +1 -0
  55. package/lib/commonjs/ui/hooks/use-keyboard-overlap.js +113 -0
  56. package/lib/commonjs/ui/hooks/use-keyboard-overlap.js.map +1 -0
  57. package/lib/commonjs/ui/hooks/use-reduce-motion.js +38 -0
  58. package/lib/commonjs/ui/hooks/use-reduce-motion.js.map +1 -0
  59. package/lib/commonjs/ui/hooks/use-widget-insets.js +41 -0
  60. package/lib/commonjs/ui/hooks/use-widget-insets.js.map +1 -0
  61. package/lib/commonjs/ui/screens/chat/ChatView.js +590 -0
  62. package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -0
  63. package/lib/commonjs/ui/screens/chat/ChatView.styles.js +362 -0
  64. package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -0
  65. package/lib/commonjs/ui/screens/chat/ChatView.types.js +6 -0
  66. package/lib/commonjs/ui/screens/chat/ChatView.types.js.map +1 -0
  67. package/lib/commonjs/ui/screens/conversations/ConversationsList.js +150 -0
  68. package/lib/commonjs/ui/screens/conversations/ConversationsList.js.map +1 -0
  69. package/lib/commonjs/ui/screens/conversations/ConversationsList.styles.js +118 -0
  70. package/lib/commonjs/ui/screens/conversations/ConversationsList.styles.js.map +1 -0
  71. package/lib/commonjs/ui/screens/conversations/ConversationsList.types.js +6 -0
  72. package/lib/commonjs/ui/screens/conversations/ConversationsList.types.js.map +1 -0
  73. package/lib/commonjs/ui/screens/home/HomeView.js +336 -0
  74. package/lib/commonjs/ui/screens/home/HomeView.js.map +1 -0
  75. package/lib/commonjs/ui/screens/home/HomeView.styles.js +245 -0
  76. package/lib/commonjs/ui/screens/home/HomeView.styles.js.map +1 -0
  77. package/lib/commonjs/ui/screens/home/HomeView.types.js +6 -0
  78. package/lib/commonjs/ui/screens/home/HomeView.types.js.map +1 -0
  79. package/lib/commonjs/ui/screens/home/greeting.js +49 -0
  80. package/lib/commonjs/ui/screens/home/greeting.js.map +1 -0
  81. package/lib/commonjs/ui/screens/pre-chat/PreChatForm.js +229 -0
  82. package/lib/commonjs/ui/screens/pre-chat/PreChatForm.js.map +1 -0
  83. package/lib/commonjs/ui/screens/pre-chat/PreChatForm.styles.js +125 -0
  84. package/lib/commonjs/ui/screens/pre-chat/PreChatForm.styles.js.map +1 -0
  85. package/lib/commonjs/ui/screens/pre-chat/PreChatForm.types.js +6 -0
  86. package/lib/commonjs/ui/screens/pre-chat/PreChatForm.types.js.map +1 -0
  87. package/lib/commonjs/ui/styles/global.js +55 -0
  88. package/lib/commonjs/ui/styles/global.js.map +1 -0
  89. package/lib/commonjs/ui/styles/theme.js +148 -0
  90. package/lib/commonjs/ui/styles/theme.js.map +1 -0
  91. package/lib/commonjs/ui/utils/conversations.js +125 -0
  92. package/lib/commonjs/ui/utils/conversations.js.map +1 -0
  93. package/lib/commonjs/ui/utils/html.js +44 -0
  94. package/lib/commonjs/ui/utils/html.js.map +1 -0
  95. package/lib/commonjs/ui/utils/native-gradient.js +65 -0
  96. package/lib/commonjs/ui/utils/native-gradient.js.map +1 -0
  97. package/lib/commonjs/ui/utils/rtl.js +56 -0
  98. package/lib/commonjs/ui/utils/rtl.js.map +1 -0
  99. package/lib/commonjs/ui/utils/safe-url.js +67 -0
  100. package/lib/commonjs/ui/utils/safe-url.js.map +1 -0
  101. package/lib/module/agent-ui-blocks.js +152 -0
  102. package/lib/module/agent-ui-blocks.js.map +1 -0
  103. package/lib/module/api/client.js +261 -0
  104. package/lib/module/api/client.js.map +1 -0
  105. package/lib/module/api/realtime.js +189 -0
  106. package/lib/module/api/realtime.js.map +1 -0
  107. package/lib/module/api/sse-parser.js +53 -0
  108. package/lib/module/api/sse-parser.js.map +1 -0
  109. package/lib/module/controller.js +19 -0
  110. package/lib/module/controller.js.map +1 -0
  111. package/lib/module/device-id.js +69 -0
  112. package/lib/module/device-id.js.map +1 -0
  113. package/lib/module/events.js +44 -0
  114. package/lib/module/events.js.map +1 -0
  115. package/lib/module/i18n.js +99 -0
  116. package/lib/module/i18n.js.map +1 -0
  117. package/lib/module/index.js +55 -0
  118. package/lib/module/index.js.map +1 -0
  119. package/lib/module/locales.js +80 -0
  120. package/lib/module/locales.js.map +1 -0
  121. package/lib/module/package.json +1 -0
  122. package/lib/module/push/push-notifications.js +204 -0
  123. package/lib/module/push/push-notifications.js.map +1 -0
  124. package/lib/module/state/use-widget-state.js +18 -0
  125. package/lib/module/state/use-widget-state.js.map +1 -0
  126. package/lib/module/state/widget-store.js +1677 -0
  127. package/lib/module/state/widget-store.js.map +1 -0
  128. package/lib/module/storage.js +141 -0
  129. package/lib/module/storage.js.map +1 -0
  130. package/lib/module/types.js +40 -0
  131. package/lib/module/types.js.map +1 -0
  132. package/lib/module/ui/WidgetProvider.js +400 -0
  133. package/lib/module/ui/WidgetProvider.js.map +1 -0
  134. package/lib/module/ui/components/AgentUiBlocks.js +857 -0
  135. package/lib/module/ui/components/AgentUiBlocks.js.map +1 -0
  136. package/lib/module/ui/components/Avatar.js +71 -0
  137. package/lib/module/ui/components/Avatar.js.map +1 -0
  138. package/lib/module/ui/components/ConversationRow.js +150 -0
  139. package/lib/module/ui/components/ConversationRow.js.map +1 -0
  140. package/lib/module/ui/components/ErrorBoundary.js +39 -0
  141. package/lib/module/ui/components/ErrorBoundary.js.map +1 -0
  142. package/lib/module/ui/components/Gradient.js +165 -0
  143. package/lib/module/ui/components/Gradient.js.map +1 -0
  144. package/lib/module/ui/components/Launcher.js +121 -0
  145. package/lib/module/ui/components/Launcher.js.map +1 -0
  146. package/lib/module/ui/components/Markdown.js +253 -0
  147. package/lib/module/ui/components/Markdown.js.map +1 -0
  148. package/lib/module/ui/components/OutboundMessageCard.js +153 -0
  149. package/lib/module/ui/components/OutboundMessageCard.js.map +1 -0
  150. package/lib/module/ui/components/WidgetPanel.js +127 -0
  151. package/lib/module/ui/components/WidgetPanel.js.map +1 -0
  152. package/lib/module/ui/components/icons.js +370 -0
  153. package/lib/module/ui/components/icons.js.map +1 -0
  154. package/lib/module/ui/hooks/use-keyboard-overlap.js +109 -0
  155. package/lib/module/ui/hooks/use-keyboard-overlap.js.map +1 -0
  156. package/lib/module/ui/hooks/use-reduce-motion.js +34 -0
  157. package/lib/module/ui/hooks/use-reduce-motion.js.map +1 -0
  158. package/lib/module/ui/hooks/use-widget-insets.js +37 -0
  159. package/lib/module/ui/hooks/use-widget-insets.js.map +1 -0
  160. package/lib/module/ui/screens/chat/ChatView.js +586 -0
  161. package/lib/module/ui/screens/chat/ChatView.js.map +1 -0
  162. package/lib/module/ui/screens/chat/ChatView.styles.js +358 -0
  163. package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -0
  164. package/lib/module/ui/screens/chat/ChatView.types.js +4 -0
  165. package/lib/module/ui/screens/chat/ChatView.types.js.map +1 -0
  166. package/lib/module/ui/screens/conversations/ConversationsList.js +146 -0
  167. package/lib/module/ui/screens/conversations/ConversationsList.js.map +1 -0
  168. package/lib/module/ui/screens/conversations/ConversationsList.styles.js +114 -0
  169. package/lib/module/ui/screens/conversations/ConversationsList.styles.js.map +1 -0
  170. package/lib/module/ui/screens/conversations/ConversationsList.types.js +4 -0
  171. package/lib/module/ui/screens/conversations/ConversationsList.types.js.map +1 -0
  172. package/lib/module/ui/screens/home/HomeView.js +332 -0
  173. package/lib/module/ui/screens/home/HomeView.js.map +1 -0
  174. package/lib/module/ui/screens/home/HomeView.styles.js +242 -0
  175. package/lib/module/ui/screens/home/HomeView.styles.js.map +1 -0
  176. package/lib/module/ui/screens/home/HomeView.types.js +4 -0
  177. package/lib/module/ui/screens/home/HomeView.types.js.map +1 -0
  178. package/lib/module/ui/screens/home/greeting.js +44 -0
  179. package/lib/module/ui/screens/home/greeting.js.map +1 -0
  180. package/lib/module/ui/screens/pre-chat/PreChatForm.js +225 -0
  181. package/lib/module/ui/screens/pre-chat/PreChatForm.js.map +1 -0
  182. package/lib/module/ui/screens/pre-chat/PreChatForm.styles.js +121 -0
  183. package/lib/module/ui/screens/pre-chat/PreChatForm.styles.js.map +1 -0
  184. package/lib/module/ui/screens/pre-chat/PreChatForm.types.js +4 -0
  185. package/lib/module/ui/screens/pre-chat/PreChatForm.types.js.map +1 -0
  186. package/lib/module/ui/styles/global.js +51 -0
  187. package/lib/module/ui/styles/global.js.map +1 -0
  188. package/lib/module/ui/styles/theme.js +137 -0
  189. package/lib/module/ui/styles/theme.js.map +1 -0
  190. package/lib/module/ui/utils/conversations.js +115 -0
  191. package/lib/module/ui/utils/conversations.js.map +1 -0
  192. package/lib/module/ui/utils/html.js +39 -0
  193. package/lib/module/ui/utils/html.js.map +1 -0
  194. package/lib/module/ui/utils/native-gradient.js +60 -0
  195. package/lib/module/ui/utils/native-gradient.js.map +1 -0
  196. package/lib/module/ui/utils/rtl.js +49 -0
  197. package/lib/module/ui/utils/rtl.js.map +1 -0
  198. package/lib/module/ui/utils/safe-url.js +59 -0
  199. package/lib/module/ui/utils/safe-url.js.map +1 -0
  200. package/lib/typescript/commonjs/agent-ui-blocks.d.ts +20 -0
  201. package/lib/typescript/commonjs/agent-ui-blocks.d.ts.map +1 -0
  202. package/lib/typescript/commonjs/api/client.d.ts +115 -0
  203. package/lib/typescript/commonjs/api/client.d.ts.map +1 -0
  204. package/lib/typescript/commonjs/api/realtime.d.ts +39 -0
  205. package/lib/typescript/commonjs/api/realtime.d.ts.map +1 -0
  206. package/lib/typescript/commonjs/api/sse-parser.d.ts +13 -0
  207. package/lib/typescript/commonjs/api/sse-parser.d.ts.map +1 -0
  208. package/lib/typescript/commonjs/controller.d.ts +15 -0
  209. package/lib/typescript/commonjs/controller.d.ts.map +1 -0
  210. package/lib/typescript/commonjs/device-id.d.ts +15 -0
  211. package/lib/typescript/commonjs/device-id.d.ts.map +1 -0
  212. package/lib/typescript/commonjs/events.d.ts +41 -0
  213. package/lib/typescript/commonjs/events.d.ts.map +1 -0
  214. package/lib/typescript/commonjs/i18n.d.ts +78 -0
  215. package/lib/typescript/commonjs/i18n.d.ts.map +1 -0
  216. package/lib/typescript/commonjs/index.d.ts +103 -0
  217. package/lib/typescript/commonjs/index.d.ts.map +1 -0
  218. package/lib/typescript/commonjs/locales.d.ts +18 -0
  219. package/lib/typescript/commonjs/locales.d.ts.map +1 -0
  220. package/lib/typescript/commonjs/package.json +1 -0
  221. package/lib/typescript/commonjs/push/push-notifications.d.ts +71 -0
  222. package/lib/typescript/commonjs/push/push-notifications.d.ts.map +1 -0
  223. package/lib/typescript/commonjs/state/use-widget-state.d.ts +10 -0
  224. package/lib/typescript/commonjs/state/use-widget-state.d.ts.map +1 -0
  225. package/lib/typescript/commonjs/state/widget-store.d.ts +227 -0
  226. package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -0
  227. package/lib/typescript/commonjs/storage.d.ts +42 -0
  228. package/lib/typescript/commonjs/storage.d.ts.map +1 -0
  229. package/lib/typescript/commonjs/types.d.ts +391 -0
  230. package/lib/typescript/commonjs/types.d.ts.map +1 -0
  231. package/lib/typescript/commonjs/ui/WidgetProvider.d.ts +15 -0
  232. package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -0
  233. package/lib/typescript/commonjs/ui/components/AgentUiBlocks.d.ts +27 -0
  234. package/lib/typescript/commonjs/ui/components/AgentUiBlocks.d.ts.map +1 -0
  235. package/lib/typescript/commonjs/ui/components/Avatar.d.ts +19 -0
  236. package/lib/typescript/commonjs/ui/components/Avatar.d.ts.map +1 -0
  237. package/lib/typescript/commonjs/ui/components/ConversationRow.d.ts +26 -0
  238. package/lib/typescript/commonjs/ui/components/ConversationRow.d.ts.map +1 -0
  239. package/lib/typescript/commonjs/ui/components/ErrorBoundary.d.ts +27 -0
  240. package/lib/typescript/commonjs/ui/components/ErrorBoundary.d.ts.map +1 -0
  241. package/lib/typescript/commonjs/ui/components/Gradient.d.ts +33 -0
  242. package/lib/typescript/commonjs/ui/components/Gradient.d.ts.map +1 -0
  243. package/lib/typescript/commonjs/ui/components/Launcher.d.ts +18 -0
  244. package/lib/typescript/commonjs/ui/components/Launcher.d.ts.map +1 -0
  245. package/lib/typescript/commonjs/ui/components/Markdown.d.ts +19 -0
  246. package/lib/typescript/commonjs/ui/components/Markdown.d.ts.map +1 -0
  247. package/lib/typescript/commonjs/ui/components/OutboundMessageCard.d.ts +20 -0
  248. package/lib/typescript/commonjs/ui/components/OutboundMessageCard.d.ts.map +1 -0
  249. package/lib/typescript/commonjs/ui/components/WidgetPanel.d.ts +26 -0
  250. package/lib/typescript/commonjs/ui/components/WidgetPanel.d.ts.map +1 -0
  251. package/lib/typescript/commonjs/ui/components/icons.d.ts +38 -0
  252. package/lib/typescript/commonjs/ui/components/icons.d.ts.map +1 -0
  253. package/lib/typescript/commonjs/ui/hooks/use-keyboard-overlap.d.ts +33 -0
  254. package/lib/typescript/commonjs/ui/hooks/use-keyboard-overlap.d.ts.map +1 -0
  255. package/lib/typescript/commonjs/ui/hooks/use-reduce-motion.d.ts +11 -0
  256. package/lib/typescript/commonjs/ui/hooks/use-reduce-motion.d.ts.map +1 -0
  257. package/lib/typescript/commonjs/ui/hooks/use-widget-insets.d.ts +16 -0
  258. package/lib/typescript/commonjs/ui/hooks/use-widget-insets.d.ts.map +1 -0
  259. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts +11 -0
  260. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -0
  261. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +349 -0
  262. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -0
  263. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +48 -0
  264. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -0
  265. package/lib/typescript/commonjs/ui/screens/conversations/ConversationsList.d.ts +10 -0
  266. package/lib/typescript/commonjs/ui/screens/conversations/ConversationsList.d.ts.map +1 -0
  267. package/lib/typescript/commonjs/ui/screens/conversations/ConversationsList.styles.d.ts +113 -0
  268. package/lib/typescript/commonjs/ui/screens/conversations/ConversationsList.styles.d.ts.map +1 -0
  269. package/lib/typescript/commonjs/ui/screens/conversations/ConversationsList.types.d.ts +31 -0
  270. package/lib/typescript/commonjs/ui/screens/conversations/ConversationsList.types.d.ts.map +1 -0
  271. package/lib/typescript/commonjs/ui/screens/home/HomeView.d.ts +17 -0
  272. package/lib/typescript/commonjs/ui/screens/home/HomeView.d.ts.map +1 -0
  273. package/lib/typescript/commonjs/ui/screens/home/HomeView.styles.d.ts +227 -0
  274. package/lib/typescript/commonjs/ui/screens/home/HomeView.styles.d.ts.map +1 -0
  275. package/lib/typescript/commonjs/ui/screens/home/HomeView.types.d.ts +40 -0
  276. package/lib/typescript/commonjs/ui/screens/home/HomeView.types.d.ts.map +1 -0
  277. package/lib/typescript/commonjs/ui/screens/home/greeting.d.ts +19 -0
  278. package/lib/typescript/commonjs/ui/screens/home/greeting.d.ts.map +1 -0
  279. package/lib/typescript/commonjs/ui/screens/pre-chat/PreChatForm.d.ts +7 -0
  280. package/lib/typescript/commonjs/ui/screens/pre-chat/PreChatForm.d.ts.map +1 -0
  281. package/lib/typescript/commonjs/ui/screens/pre-chat/PreChatForm.styles.d.ts +115 -0
  282. package/lib/typescript/commonjs/ui/screens/pre-chat/PreChatForm.styles.d.ts.map +1 -0
  283. package/lib/typescript/commonjs/ui/screens/pre-chat/PreChatForm.types.d.ts +20 -0
  284. package/lib/typescript/commonjs/ui/screens/pre-chat/PreChatForm.types.d.ts.map +1 -0
  285. package/lib/typescript/commonjs/ui/styles/global.d.ts +46 -0
  286. package/lib/typescript/commonjs/ui/styles/global.d.ts.map +1 -0
  287. package/lib/typescript/commonjs/ui/styles/theme.d.ts +103 -0
  288. package/lib/typescript/commonjs/ui/styles/theme.d.ts.map +1 -0
  289. package/lib/typescript/commonjs/ui/utils/conversations.d.ts +41 -0
  290. package/lib/typescript/commonjs/ui/utils/conversations.d.ts.map +1 -0
  291. package/lib/typescript/commonjs/ui/utils/html.d.ts +10 -0
  292. package/lib/typescript/commonjs/ui/utils/html.d.ts.map +1 -0
  293. package/lib/typescript/commonjs/ui/utils/native-gradient.d.ts +61 -0
  294. package/lib/typescript/commonjs/ui/utils/native-gradient.d.ts.map +1 -0
  295. package/lib/typescript/commonjs/ui/utils/rtl.d.ts +33 -0
  296. package/lib/typescript/commonjs/ui/utils/rtl.d.ts.map +1 -0
  297. package/lib/typescript/commonjs/ui/utils/safe-url.d.ts +21 -0
  298. package/lib/typescript/commonjs/ui/utils/safe-url.d.ts.map +1 -0
  299. package/lib/typescript/module/agent-ui-blocks.d.ts +20 -0
  300. package/lib/typescript/module/agent-ui-blocks.d.ts.map +1 -0
  301. package/lib/typescript/module/api/client.d.ts +115 -0
  302. package/lib/typescript/module/api/client.d.ts.map +1 -0
  303. package/lib/typescript/module/api/realtime.d.ts +39 -0
  304. package/lib/typescript/module/api/realtime.d.ts.map +1 -0
  305. package/lib/typescript/module/api/sse-parser.d.ts +13 -0
  306. package/lib/typescript/module/api/sse-parser.d.ts.map +1 -0
  307. package/lib/typescript/module/controller.d.ts +15 -0
  308. package/lib/typescript/module/controller.d.ts.map +1 -0
  309. package/lib/typescript/module/device-id.d.ts +15 -0
  310. package/lib/typescript/module/device-id.d.ts.map +1 -0
  311. package/lib/typescript/module/events.d.ts +41 -0
  312. package/lib/typescript/module/events.d.ts.map +1 -0
  313. package/lib/typescript/module/i18n.d.ts +78 -0
  314. package/lib/typescript/module/i18n.d.ts.map +1 -0
  315. package/lib/typescript/module/index.d.ts +103 -0
  316. package/lib/typescript/module/index.d.ts.map +1 -0
  317. package/lib/typescript/module/locales.d.ts +18 -0
  318. package/lib/typescript/module/locales.d.ts.map +1 -0
  319. package/lib/typescript/module/package.json +1 -0
  320. package/lib/typescript/module/push/push-notifications.d.ts +71 -0
  321. package/lib/typescript/module/push/push-notifications.d.ts.map +1 -0
  322. package/lib/typescript/module/state/use-widget-state.d.ts +10 -0
  323. package/lib/typescript/module/state/use-widget-state.d.ts.map +1 -0
  324. package/lib/typescript/module/state/widget-store.d.ts +227 -0
  325. package/lib/typescript/module/state/widget-store.d.ts.map +1 -0
  326. package/lib/typescript/module/storage.d.ts +42 -0
  327. package/lib/typescript/module/storage.d.ts.map +1 -0
  328. package/lib/typescript/module/types.d.ts +391 -0
  329. package/lib/typescript/module/types.d.ts.map +1 -0
  330. package/lib/typescript/module/ui/WidgetProvider.d.ts +15 -0
  331. package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -0
  332. package/lib/typescript/module/ui/components/AgentUiBlocks.d.ts +27 -0
  333. package/lib/typescript/module/ui/components/AgentUiBlocks.d.ts.map +1 -0
  334. package/lib/typescript/module/ui/components/Avatar.d.ts +19 -0
  335. package/lib/typescript/module/ui/components/Avatar.d.ts.map +1 -0
  336. package/lib/typescript/module/ui/components/ConversationRow.d.ts +26 -0
  337. package/lib/typescript/module/ui/components/ConversationRow.d.ts.map +1 -0
  338. package/lib/typescript/module/ui/components/ErrorBoundary.d.ts +27 -0
  339. package/lib/typescript/module/ui/components/ErrorBoundary.d.ts.map +1 -0
  340. package/lib/typescript/module/ui/components/Gradient.d.ts +33 -0
  341. package/lib/typescript/module/ui/components/Gradient.d.ts.map +1 -0
  342. package/lib/typescript/module/ui/components/Launcher.d.ts +18 -0
  343. package/lib/typescript/module/ui/components/Launcher.d.ts.map +1 -0
  344. package/lib/typescript/module/ui/components/Markdown.d.ts +19 -0
  345. package/lib/typescript/module/ui/components/Markdown.d.ts.map +1 -0
  346. package/lib/typescript/module/ui/components/OutboundMessageCard.d.ts +20 -0
  347. package/lib/typescript/module/ui/components/OutboundMessageCard.d.ts.map +1 -0
  348. package/lib/typescript/module/ui/components/WidgetPanel.d.ts +26 -0
  349. package/lib/typescript/module/ui/components/WidgetPanel.d.ts.map +1 -0
  350. package/lib/typescript/module/ui/components/icons.d.ts +38 -0
  351. package/lib/typescript/module/ui/components/icons.d.ts.map +1 -0
  352. package/lib/typescript/module/ui/hooks/use-keyboard-overlap.d.ts +33 -0
  353. package/lib/typescript/module/ui/hooks/use-keyboard-overlap.d.ts.map +1 -0
  354. package/lib/typescript/module/ui/hooks/use-reduce-motion.d.ts +11 -0
  355. package/lib/typescript/module/ui/hooks/use-reduce-motion.d.ts.map +1 -0
  356. package/lib/typescript/module/ui/hooks/use-widget-insets.d.ts +16 -0
  357. package/lib/typescript/module/ui/hooks/use-widget-insets.d.ts.map +1 -0
  358. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts +11 -0
  359. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -0
  360. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +349 -0
  361. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -0
  362. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +48 -0
  363. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -0
  364. package/lib/typescript/module/ui/screens/conversations/ConversationsList.d.ts +10 -0
  365. package/lib/typescript/module/ui/screens/conversations/ConversationsList.d.ts.map +1 -0
  366. package/lib/typescript/module/ui/screens/conversations/ConversationsList.styles.d.ts +113 -0
  367. package/lib/typescript/module/ui/screens/conversations/ConversationsList.styles.d.ts.map +1 -0
  368. package/lib/typescript/module/ui/screens/conversations/ConversationsList.types.d.ts +31 -0
  369. package/lib/typescript/module/ui/screens/conversations/ConversationsList.types.d.ts.map +1 -0
  370. package/lib/typescript/module/ui/screens/home/HomeView.d.ts +17 -0
  371. package/lib/typescript/module/ui/screens/home/HomeView.d.ts.map +1 -0
  372. package/lib/typescript/module/ui/screens/home/HomeView.styles.d.ts +227 -0
  373. package/lib/typescript/module/ui/screens/home/HomeView.styles.d.ts.map +1 -0
  374. package/lib/typescript/module/ui/screens/home/HomeView.types.d.ts +40 -0
  375. package/lib/typescript/module/ui/screens/home/HomeView.types.d.ts.map +1 -0
  376. package/lib/typescript/module/ui/screens/home/greeting.d.ts +19 -0
  377. package/lib/typescript/module/ui/screens/home/greeting.d.ts.map +1 -0
  378. package/lib/typescript/module/ui/screens/pre-chat/PreChatForm.d.ts +7 -0
  379. package/lib/typescript/module/ui/screens/pre-chat/PreChatForm.d.ts.map +1 -0
  380. package/lib/typescript/module/ui/screens/pre-chat/PreChatForm.styles.d.ts +115 -0
  381. package/lib/typescript/module/ui/screens/pre-chat/PreChatForm.styles.d.ts.map +1 -0
  382. package/lib/typescript/module/ui/screens/pre-chat/PreChatForm.types.d.ts +20 -0
  383. package/lib/typescript/module/ui/screens/pre-chat/PreChatForm.types.d.ts.map +1 -0
  384. package/lib/typescript/module/ui/styles/global.d.ts +46 -0
  385. package/lib/typescript/module/ui/styles/global.d.ts.map +1 -0
  386. package/lib/typescript/module/ui/styles/theme.d.ts +103 -0
  387. package/lib/typescript/module/ui/styles/theme.d.ts.map +1 -0
  388. package/lib/typescript/module/ui/utils/conversations.d.ts +41 -0
  389. package/lib/typescript/module/ui/utils/conversations.d.ts.map +1 -0
  390. package/lib/typescript/module/ui/utils/html.d.ts +10 -0
  391. package/lib/typescript/module/ui/utils/html.d.ts.map +1 -0
  392. package/lib/typescript/module/ui/utils/native-gradient.d.ts +61 -0
  393. package/lib/typescript/module/ui/utils/native-gradient.d.ts.map +1 -0
  394. package/lib/typescript/module/ui/utils/rtl.d.ts +33 -0
  395. package/lib/typescript/module/ui/utils/rtl.d.ts.map +1 -0
  396. package/lib/typescript/module/ui/utils/safe-url.d.ts +21 -0
  397. package/lib/typescript/module/ui/utils/safe-url.d.ts.map +1 -0
  398. package/metro.js +40 -0
  399. package/package.json +116 -0
  400. package/src/agent-ui-blocks.ts +302 -0
  401. package/src/api/client.ts +332 -0
  402. package/src/api/realtime.ts +225 -0
  403. package/src/api/sse-parser.ts +51 -0
  404. package/src/controller.ts +20 -0
  405. package/src/device-id.ts +71 -0
  406. package/src/events.ts +74 -0
  407. package/src/i18n.ts +114 -0
  408. package/src/index.tsx +199 -0
  409. package/src/locales.ts +80 -0
  410. package/src/push/push-notifications.ts +254 -0
  411. package/src/state/use-widget-state.ts +20 -0
  412. package/src/state/widget-store.ts +1815 -0
  413. package/src/storage.ts +153 -0
  414. package/src/types.ts +445 -0
  415. package/src/ui/WidgetProvider.tsx +434 -0
  416. package/src/ui/components/AgentUiBlocks.tsx +912 -0
  417. package/src/ui/components/Avatar.tsx +86 -0
  418. package/src/ui/components/ConversationRow.tsx +163 -0
  419. package/src/ui/components/ErrorBoundary.tsx +49 -0
  420. package/src/ui/components/Gradient.tsx +165 -0
  421. package/src/ui/components/Launcher.tsx +128 -0
  422. package/src/ui/components/Markdown.tsx +291 -0
  423. package/src/ui/components/OutboundMessageCard.tsx +154 -0
  424. package/src/ui/components/WidgetPanel.tsx +133 -0
  425. package/src/ui/components/icons.tsx +315 -0
  426. package/src/ui/hooks/use-keyboard-overlap.ts +134 -0
  427. package/src/ui/hooks/use-reduce-motion.ts +39 -0
  428. package/src/ui/hooks/use-widget-insets.ts +44 -0
  429. package/src/ui/screens/chat/ChatView.styles.ts +357 -0
  430. package/src/ui/screens/chat/ChatView.tsx +690 -0
  431. package/src/ui/screens/chat/ChatView.types.ts +46 -0
  432. package/src/ui/screens/conversations/ConversationsList.styles.ts +111 -0
  433. package/src/ui/screens/conversations/ConversationsList.tsx +144 -0
  434. package/src/ui/screens/conversations/ConversationsList.types.ts +33 -0
  435. package/src/ui/screens/home/HomeView.styles.ts +246 -0
  436. package/src/ui/screens/home/HomeView.tsx +336 -0
  437. package/src/ui/screens/home/HomeView.types.ts +42 -0
  438. package/src/ui/screens/home/greeting.ts +50 -0
  439. package/src/ui/screens/pre-chat/PreChatForm.styles.ts +120 -0
  440. package/src/ui/screens/pre-chat/PreChatForm.tsx +221 -0
  441. package/src/ui/screens/pre-chat/PreChatForm.types.ts +22 -0
  442. package/src/ui/styles/global.ts +50 -0
  443. package/src/ui/styles/theme.ts +155 -0
  444. package/src/ui/utils/conversations.ts +122 -0
  445. package/src/ui/utils/html.ts +58 -0
  446. package/src/ui/utils/native-gradient.ts +60 -0
  447. package/src/ui/utils/rtl.ts +43 -0
  448. package/src/ui/utils/safe-url.ts +63 -0
@@ -0,0 +1,1677 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * The widget's single source of truth.
5
+ *
6
+ * Ported from the web widget's `client/src/widget/hooks/use-widget-state.ts`
7
+ * plus the routing/tab logic in `client/src/widget/App.tsx`. Unlike the web
8
+ * version this is a plain store rather than a hook, because the SSE connection
9
+ * and the unread count must stay alive while the UI is closed — the modal is
10
+ * only a subscriber.
11
+ */
12
+
13
+ import { AppState, Platform } from 'react-native';
14
+ import { isTerminalErrorCode, RepzoWidgetError, WidgetApiClient } from "../api/client.js";
15
+ import { WidgetRealtime } from "../api/realtime.js";
16
+ import { getDeviceId, rotateDeviceId } from "../device-id.js";
17
+ import { EventEmitter, RepzoWidgetEvents } from "../events.js";
18
+ import { PushManager } from "../push/push-notifications.js";
19
+ import { CONFIG_KEY, LEGACY_CONFIG_KEY, SecureStorage, Storage, UNREAD_KEY, USER_KEY } from "../storage.js";
20
+ import { DEFAULT_BASE_URL } from "../types.js";
21
+ import { setOpenUrlHandler } from "../ui/utils/safe-url.js";
22
+ const SDK_VERSION = '@repzo/widget-react-native@1.0.0';
23
+
24
+ /** The platform this session reports — mobile sessions get the Mobile SDKs overrides. */
25
+ function sessionPlatform() {
26
+ return Platform.OS === 'ios' || Platform.OS === 'android' ? Platform.OS : 'web';
27
+ }
28
+
29
+ /** CONFIG_KEY entry: the session config plus where it came from. */
30
+
31
+ const TYPING_CLEAR_MS = 30_000;
32
+ const TYPING_SETTLE_MS = 3_000;
33
+ /** Visitor → agent typing pings are rate limited at 30/min server-side. */
34
+ const TYPING_PING_INTERVAL_MS = 4_000;
35
+ /** Guards against the SSE stream replaying messages after a reconnect. */
36
+ const SEEN_MESSAGE_LIMIT = 100;
37
+ /** Logout's server calls are best-effort — a dead network must not block sign-out. */
38
+ const LOGOUT_REQUEST_TIMEOUT_MS = 5_000;
39
+ /** A server that 401s every fresh stream must not spin init (10/min limit). */
40
+ const STREAM_REAUTH_MIN_INTERVAL_MS = 30_000;
41
+ /** Live replies in the open thread advance the server read cursor, batched. */
42
+ const MARK_READ_DEBOUNCE_MS = 1_000;
43
+ /** Live events held back while a draft's first send is creating its thread. */
44
+ const DRAFT_EVENT_BUFFER_LIMIT = 50;
45
+ /** Plain-http `baseUrl` is only accepted for these (plus `*.local` and `__DEV__`). */
46
+ const LOCAL_DEV_HOSTS = new Set(['localhost', '127.0.0.1', '10.0.2.2']);
47
+ const sumUnread = map => Object.values(map).reduce((sum, count) => sum + count, 0);
48
+ const INITIAL_STATE = {
49
+ booted: false,
50
+ loading: false,
51
+ error: null,
52
+ errorCode: null,
53
+ config: null,
54
+ identityId: null,
55
+ isOpen: false,
56
+ view: 'bubble',
57
+ activeTab: 'home',
58
+ conversations: [],
59
+ activeConversationId: null,
60
+ messages: [],
61
+ messagesLoading: false,
62
+ hasMoreMessages: false,
63
+ messageCursor: null,
64
+ agentTyping: false,
65
+ unreadCount: 0,
66
+ unreadByConversation: {},
67
+ locale: 'en',
68
+ prefillText: '',
69
+ outboundMessage: null,
70
+ visitorProfile: {}
71
+ };
72
+
73
+ /** Everything in state that belongs to the signed-in visitor. */
74
+ const USER_SCOPED_STATE = {
75
+ // A wiped session is not booted until the next init lands.
76
+ booted: false,
77
+ identityId: null,
78
+ conversations: [],
79
+ activeConversationId: null,
80
+ messages: [],
81
+ messagesLoading: false,
82
+ hasMoreMessages: false,
83
+ messageCursor: null,
84
+ agentTyping: false,
85
+ unreadCount: 0,
86
+ unreadByConversation: {},
87
+ prefillText: '',
88
+ outboundMessage: null,
89
+ visitorProfile: {}
90
+ };
91
+ export class WidgetStore {
92
+ client = new WidgetApiClient();
93
+ events = new EventEmitter();
94
+ push = new PushManager(this.client, conversationId => {
95
+ void this.openConversation(conversationId);
96
+ });
97
+ state = INITIAL_STATE;
98
+ subscribers = new Set();
99
+ realtime = new WidgetRealtime();
100
+ typingTimer = null;
101
+ pendingAgentByConversation = {};
102
+ bootConfig = null;
103
+ bootPromise = null;
104
+
105
+ /** Identity key of the in-flight boot — a boot for someone else is not deduped. */
106
+ bootingUserKey = null;
107
+ currentUser = null;
108
+
109
+ /** Who the stored token was issued to; null = anonymous. */
110
+ currentUserKey = null;
111
+
112
+ /** True once `currentUserKey` mirrors storage — memory is then authoritative. */
113
+ userKeyLoaded = false;
114
+ pendingConversationId = null;
115
+ appStateSubscription = null;
116
+ lastTypingPingAt = 0;
117
+ visitorTypingActive = false;
118
+ seenMessageIds = [];
119
+
120
+ /**
121
+ * Identity generation. Bumped whenever boot/login/logout is requested; every
122
+ * async path that writes session state captures it first and bails once it
123
+ * moved, so a slow init for user A can never land after B logged in or
124
+ * after logout.
125
+ */
126
+ epoch = 0;
127
+
128
+ /** Serializes identity ops (and 401 re-inits) — one init or wipe at a time. */
129
+ identityQueue = Promise.resolve();
130
+
131
+ /** Shared by concurrent 401s: one re-init, not one per failed request. */
132
+ reauthPromise = null;
133
+
134
+ /** Set by logout(); blocks 401 re-init and realtime until the next boot/login. */
135
+ loggedOut = false;
136
+
137
+ /**
138
+ * True during wipeSession(). A 401 from a request still on the wire (a push
139
+ * registration it waits for) must not queue a re-init behind the wipe.
140
+ */
141
+ wiping = false;
142
+
143
+ /** Set by a terminal 403 — stops realtime and re-init until a successful init. */
144
+ terminalErrorCode = null;
145
+ lastStreamReauthAt = 0;
146
+
147
+ /** Queued/running boot, login and logout ops — Retry must not cut in. */
148
+ identityOpsInFlight = 0;
149
+
150
+ /**
151
+ * Bumped whenever a different thread (or a fresh draft) takes the screen.
152
+ * Message loads capture it and drop their result once it moved, so a slow
153
+ * response for A never paints under B.
154
+ */
155
+ threadGeneration = 0;
156
+
157
+ /**
158
+ * The in-flight first send of a draft, which creates its conversation.
159
+ * Sends from the same draft wait for its id instead of creating another.
160
+ */
161
+ conversationCreation = null;
162
+
163
+ /** Live events for a thread that may be the one the draft is creating. */
164
+ draftEvents = [];
165
+
166
+ /** False until this stream's first `connected`; later ones are reconnects. */
167
+ streamConnectedOnce = false;
168
+
169
+ /** The stream errored before its first `connected` — that one is a reconnect too. */
170
+ streamErrored = false;
171
+ readTimer = null;
172
+ readConversationId = null;
173
+ constructor() {
174
+ this.client.reauthorize = failedToken => this.reauthorize(failedToken);
175
+ this.client.onTerminalError = error => this.handleTerminalError(error);
176
+ }
177
+
178
+ // ─── Subscription plumbing ──────────────────────────────────────────────
179
+
180
+ subscribe = listener => {
181
+ this.subscribers.add(listener);
182
+ return () => {
183
+ this.subscribers.delete(listener);
184
+ };
185
+ };
186
+ getState = () => this.state;
187
+ setState(patch) {
188
+ const previous = this.state;
189
+ const resolved = typeof patch === 'function' ? patch(previous) : patch;
190
+ const next = {
191
+ ...previous,
192
+ ...resolved
193
+ };
194
+ // The total is always derived from the per-thread map, so no surface can
195
+ // zero the badge while threads stay unread.
196
+ next.unreadCount = sumUnread(next.unreadByConversation);
197
+ this.state = next;
198
+ if (next.unreadCount !== previous.unreadCount) {
199
+ this.events.emit(RepzoWidgetEvents.UnreadCountDidChange, {
200
+ count: next.unreadCount
201
+ });
202
+ }
203
+ if (next.isOpen !== previous.isOpen) {
204
+ this.events.emit(next.isOpen ? RepzoWidgetEvents.WindowDidShow : RepzoWidgetEvents.WindowDidHide, undefined);
205
+ }
206
+ this.subscribers.forEach(listener => listener());
207
+ }
208
+
209
+ // ─── Identity queue ─────────────────────────────────────────────────────
210
+
211
+ serialize(op) {
212
+ const run = this.identityQueue.then(op);
213
+ this.identityQueue = run.catch(() => {});
214
+ return run;
215
+ }
216
+
217
+ /** Queue a requested identity op; anything older becomes stale right away. */
218
+ enqueueIdentity(op) {
219
+ const epoch = ++this.epoch;
220
+ // A pending boot is superseded — a later boot() must not dedupe onto it.
221
+ this.bootPromise = null;
222
+ this.bootingUserKey = null;
223
+ this.identityOpsInFlight += 1;
224
+ return this.serialize(() => op(epoch)).finally(() => {
225
+ this.identityOpsInFlight -= 1;
226
+ });
227
+ }
228
+ isStale(epoch) {
229
+ return epoch !== this.epoch;
230
+ }
231
+
232
+ // ─── Boot / identity ────────────────────────────────────────────────────
233
+
234
+ async boot(options) {
235
+ if (!options?.channelAccountId) {
236
+ throw new RepzoWidgetError('boot() requires channelAccountId', 0, 'http');
237
+ }
238
+ const baseUrl = assertSecureBaseUrl(options.baseUrl?.trim() || DEFAULT_BASE_URL);
239
+ if (options.onOpenUrl !== undefined) setOpenUrlHandler(options.onOpenUrl ?? null);
240
+ const sameTarget = this.bootConfig?.baseUrl === baseUrl && this.bootConfig?.channelAccountId === options.channelAccountId;
241
+ const userKey = options.user ? identityKey(options.user) : null;
242
+ if (sameTarget && this.bootPromise && (!userKey || userKey === this.bootingUserKey)) {
243
+ return this.bootPromise;
244
+ }
245
+ if (sameTarget && this.state.booted && !this.state.error && !this.userChanged(options.user)) {
246
+ return;
247
+ }
248
+ this.bootConfig = {
249
+ baseUrl,
250
+ channelAccountId: options.channelAccountId
251
+ };
252
+ this.client.configure(baseUrl, options.channelAccountId);
253
+ if (options.push) this.push.configure(options.push);
254
+
255
+ // Paint with the last known theme immediately — without this the UI
256
+ // flashes the built-in defaults (color, labels) until init returns.
257
+ void this.seedCachedConfig();
258
+ const user = options.user;
259
+ const promise = this.enqueueIdentity(async epoch => {
260
+ if (this.isStale(epoch)) return;
261
+ this.loggedOut = false;
262
+ this.currentUser = user ?? this.currentUser;
263
+ try {
264
+ await this.establishSession(user, epoch);
265
+ } catch (error) {
266
+ // Boot failures must not crash the host app — surface through state.
267
+ if (!this.isStale(epoch)) {
268
+ this.setState({
269
+ loading: false,
270
+ error: error instanceof Error ? error.message : 'Failed to load chat',
271
+ errorCode: error instanceof RepzoWidgetError ? error.code : 'http'
272
+ });
273
+ }
274
+ throw error;
275
+ }
276
+ }).finally(() => {
277
+ if (this.bootPromise === promise) {
278
+ this.bootPromise = null;
279
+ this.bootingUserKey = null;
280
+ }
281
+ });
282
+ this.bootPromise = promise;
283
+ this.bootingUserKey = userKey;
284
+ return promise;
285
+ }
286
+ userChanged(user) {
287
+ if (!user) return false;
288
+ return identityKey(user) !== this.currentUserKey;
289
+ }
290
+
291
+ /**
292
+ * Init for `user` — or, when undefined, for whoever the stored token
293
+ * belongs to. The stored token is only resumed when it is anonymous or
294
+ * already belongs to `user`: resuming another identified user's token would
295
+ * hand their conversations to this one, so that case signs them out first.
296
+ */
297
+ async establishSession(user, epoch) {
298
+ let resumeToken = true;
299
+ if (user) {
300
+ const storedKey = await this.storedUserKey();
301
+ if (this.isStale(epoch)) return;
302
+ if (storedKey && storedKey !== identityKey(user)) {
303
+ await this.wipeSession();
304
+ if (this.isStale(epoch)) return;
305
+ resumeToken = false;
306
+ }
307
+ this.currentUser = user;
308
+ }
309
+ await this.runInit({
310
+ user,
311
+ resumeToken,
312
+ epoch
313
+ });
314
+ }
315
+ async runInit(params) {
316
+ this.setState({
317
+ loading: true,
318
+ error: null,
319
+ errorCode: null
320
+ });
321
+ try {
322
+ await this.initSession(params);
323
+ } catch (error) {
324
+ if (!this.isStale(params.epoch)) {
325
+ if (error instanceof RepzoWidgetError && isTerminalErrorCode(error.code)) {
326
+ this.handleTerminalError(error);
327
+ } else if (!this.state.booted) {
328
+ // No usable session: surface it so the UI shows Retry instead of a
329
+ // chat that looks alive. A failed silent re-init of a booted
330
+ // session stays quiet — the next request or foreground retries.
331
+ this.setState({
332
+ loading: false,
333
+ error: error instanceof Error ? error.message : 'Failed to load chat',
334
+ errorCode: error instanceof RepzoWidgetError ? error.code : 'http'
335
+ });
336
+ } else {
337
+ this.setState({
338
+ loading: false
339
+ });
340
+ }
341
+ // Foreground resume is how a failed boot recovers on its own.
342
+ if (!this.loggedOut) this.watchAppState();
343
+ }
344
+ throw error;
345
+ }
346
+ }
347
+
348
+ /** Every `await` is followed by an epoch check: stale results write nothing. */
349
+ async initSession(params) {
350
+ const {
351
+ user,
352
+ epoch
353
+ } = params;
354
+ const trackingVisitorId = await getDeviceId();
355
+ if (this.isStale(epoch)) return;
356
+ // Read before init: a resumed identified session keeps its key.
357
+ const priorKey = user ? null : await this.storedUserKey();
358
+ if (this.isStale(epoch)) return;
359
+ const result = await this.client.init({
360
+ user,
361
+ trackingVisitorId,
362
+ platform: sessionPlatform(),
363
+ sdkVersion: SDK_VERSION,
364
+ resumeToken: params.resumeToken
365
+ });
366
+ if (this.isStale(epoch)) return;
367
+
368
+ // Only a session the server actually identified (init echoes `user`) is
369
+ // keyed to a user; anything else is anonymous and safe to resume later.
370
+ let userKey = null;
371
+ if (result.user) {
372
+ if (user) userKey = identityKey(user);else userKey = priorKey ?? (result.user.email ? identityKey({
373
+ email: result.user.email
374
+ }) : null);
375
+ }
376
+ this.currentUserKey = userKey;
377
+ this.userKeyLoaded = true;
378
+ this.terminalErrorCode = null;
379
+ // Token and user key land together, before the next stale check — a
380
+ // queued op must never see one without the other.
381
+ await Promise.all([this.client.setToken(result.token), userKey ? SecureStorage.setItem(USER_KEY, userKey) : SecureStorage.removeItem(USER_KEY)]);
382
+ if (this.isStale(epoch)) return;
383
+ const conversations = result.conversations.map(conversation => this.hydrateConversation(conversation));
384
+
385
+ // Server counts win (they include replies pushed while the app was
386
+ // killed); the locally persisted map only covers older Workstation
387
+ // deployments that don't send unreadCount yet.
388
+ const storedUnread = await this.loadUnread();
389
+ if (this.isStale(epoch)) return;
390
+ const unreadByConversation = {};
391
+ for (const conversation of conversations) {
392
+ const count = typeof conversation.unreadCount === 'number' ? conversation.unreadCount : storedUnread[conversation.id] ?? 0;
393
+ if (count > 0) unreadByConversation[conversation.id] = count;
394
+ }
395
+ // A silent re-init can land while a thread is on screen — never badge it.
396
+ const onScreenId = this.state.activeConversationId;
397
+ if (onScreenId && this.isThreadOnScreen(onScreenId)) delete unreadByConversation[onScreenId];
398
+ this.persistUnread(unreadByConversation);
399
+ this.setState(current => ({
400
+ booted: true,
401
+ loading: false,
402
+ error: null,
403
+ errorCode: null,
404
+ config: result.config,
405
+ identityId: result.identityId,
406
+ conversations,
407
+ // Also seeds the launcher badge, so a reply that landed while the app
408
+ // was closed still shows on the host's icon.
409
+ unreadByConversation,
410
+ locale: current.locale === INITIAL_STATE.locale ? result.config.defaultLocale : current.locale,
411
+ activeTab: firstEnabledSpace(result.config?.spaces) ?? current.activeTab,
412
+ visitorProfile: {
413
+ ...current.visitorProfile,
414
+ ...(user?.name ? {
415
+ name: user.name
416
+ } : {}),
417
+ ...(user?.email ? {
418
+ email: user.email
419
+ } : {}),
420
+ ...(user?.phone ? {
421
+ phone: user.phone
422
+ } : {}),
423
+ // Init echoes back the identity the backend holds for this visitor.
424
+ // It is the authority once identity verification has run, so it wins
425
+ // over whatever the host passed in — and it is the only source when
426
+ // the host boots without user attributes at all.
427
+ ...(result.user?.name ? {
428
+ name: result.user.name
429
+ } : {}),
430
+ ...(result.user?.email ? {
431
+ email: result.user.email
432
+ } : {})
433
+ }
434
+ }));
435
+ this.persistConfig(result.config);
436
+ this.connectRealtime(result.token);
437
+ this.watchAppState();
438
+ this.loadOutboundMessage();
439
+ // Re-runs on login switches too — the token moves to the new identity.
440
+ void this.push.start();
441
+ if (this.pendingConversationId) {
442
+ const conversationId = this.pendingConversationId;
443
+ this.pendingConversationId = null;
444
+ void this.openConversation(conversationId);
445
+ } else {
446
+ // A re-init after a 401 means the stream was down: catch the open
447
+ // thread up (a no-op when nothing is on screen).
448
+ void this.refreshOpenThread();
449
+ }
450
+ }
451
+
452
+ /**
453
+ * The last config this device received, applied before init returns so the
454
+ * UI opens with the workspace's real theme instead of the built-in defaults.
455
+ * Skipped once a fresh config is in state — never downgrades live data.
456
+ */
457
+ async seedCachedConfig() {
458
+ try {
459
+ void Storage.removeItem(LEGACY_CONFIG_KEY);
460
+ const raw = await Storage.getItem(CONFIG_KEY);
461
+ if (!raw || this.state.config) return;
462
+ const cached = JSON.parse(raw);
463
+ // Only a config cached by a session init for this same widget, server
464
+ // and platform is painted — anything else (another channel, the web
465
+ // design) would flash before init replaces it, so the loading state
466
+ // shows instead.
467
+ if (cached?.v === 2 && cached.config && typeof cached.config === 'object' && cached.baseUrl === this.bootConfig?.baseUrl && cached.channelAccountId === this.bootConfig?.channelAccountId && cached.platform === sessionPlatform() && !this.state.config) {
468
+ this.setState({
469
+ config: cached.config
470
+ });
471
+ }
472
+ } catch {
473
+ // Corrupt cache — the network config overwrites it shortly.
474
+ }
475
+ }
476
+ persistConfig(config) {
477
+ if (!this.bootConfig) return;
478
+ const entry = {
479
+ v: 2,
480
+ baseUrl: this.bootConfig.baseUrl,
481
+ channelAccountId: this.bootConfig.channelAccountId,
482
+ platform: sessionPlatform(),
483
+ config
484
+ };
485
+ try {
486
+ void Storage.setItem(CONFIG_KEY, JSON.stringify(entry));
487
+ } catch {
488
+ // Cache miss next launch at worst.
489
+ }
490
+ }
491
+ async storedUserKey() {
492
+ if (this.userKeyLoaded) return this.currentUserKey;
493
+ const stored = await SecureStorage.getItem(USER_KEY);
494
+ // Another op may have written the key while storage was being read.
495
+ if (this.userKeyLoaded) return this.currentUserKey;
496
+ this.currentUserKey = stored || null;
497
+ this.userKeyLoaded = true;
498
+ return this.currentUserKey;
499
+ }
500
+
501
+ /**
502
+ * Called by the API client after a 401. Single-flight: concurrent 401s share
503
+ * one re-init. Runs in the identity queue but does not bump the epoch, so it
504
+ * can't cancel a boot/login queued behind it — and it gives up if one was.
505
+ */
506
+ reauthorize(failedToken) {
507
+ if (this.loggedOut || this.wiping || this.terminalErrorCode || !this.bootConfig) {
508
+ return Promise.resolve(null);
509
+ }
510
+ if (this.reauthPromise) return this.reauthPromise;
511
+ const epoch = this.epoch;
512
+ const promise = this.serialize(async () => {
513
+ if (this.loggedOut || this.terminalErrorCode || this.isStale(epoch)) return null;
514
+ // Someone already replaced the token that failed — just retry with it.
515
+ const current = await this.client.getToken();
516
+ if (this.isStale(epoch)) return null;
517
+ if (current && current !== failedToken) return current;
518
+ try {
519
+ await this.runInit({
520
+ user: this.currentUser ?? undefined,
521
+ resumeToken: false,
522
+ epoch
523
+ });
524
+ } catch {
525
+ return null;
526
+ }
527
+ if (this.isStale(epoch)) return null;
528
+ return this.client.getToken();
529
+ }).finally(() => {
530
+ if (this.reauthPromise === promise) this.reauthPromise = null;
531
+ });
532
+ this.reauthPromise = promise;
533
+ return promise;
534
+ }
535
+
536
+ /** A terminal 403 (`messenger_api_disabled`, `identity_verification_failed`). */
537
+ handleTerminalError(error) {
538
+ this.terminalErrorCode = error.code;
539
+ this.realtime.close();
540
+ this.setState({
541
+ loading: false,
542
+ error: error.message,
543
+ errorCode: error.code
544
+ });
545
+ }
546
+ async loginUnidentifiedUser() {
547
+ this.requireBootConfig();
548
+ return this.enqueueIdentity(async epoch => {
549
+ if (this.isStale(epoch)) return;
550
+ const storedKey = await this.storedUserKey();
551
+ if (this.isStale(epoch)) return;
552
+ if (this.state.booted && !this.state.error && !storedKey) return;
553
+ this.loggedOut = false;
554
+ this.currentUser = null;
555
+ // A signed-in user is signed out properly; an anonymous session is
556
+ // just dropped for a fresh one.
557
+ if (storedKey) await this.wipeSession();else await this.client.clearToken();
558
+ if (this.isStale(epoch)) return;
559
+ await this.runInit({
560
+ user: undefined,
561
+ resumeToken: false,
562
+ epoch
563
+ });
564
+ });
565
+ }
566
+ async loginUserWithUserAttributes(user) {
567
+ this.requireBootConfig();
568
+ if (!user?.userId && !user?.email) {
569
+ throw new RepzoWidgetError('loginUserWithUserAttributes requires userId or email', 0, 'http');
570
+ }
571
+ return this.enqueueIdentity(async epoch => {
572
+ if (this.isStale(epoch)) return;
573
+ this.loggedOut = false;
574
+ const key = identityKey(user);
575
+ const storedKey = await this.storedUserKey();
576
+ if (this.isStale(epoch)) return;
577
+
578
+ // Same person as the resumed session — the token already points at their
579
+ // identity, so skip the (rate-limited) init round trip.
580
+ if (this.state.booted && !this.state.error && storedKey === key) {
581
+ this.currentUser = user;
582
+ this.setState(current => ({
583
+ visitorProfile: {
584
+ ...current.visitorProfile,
585
+ ...(user.name ? {
586
+ name: user.name
587
+ } : {}),
588
+ ...(user.email ? {
589
+ email: user.email
590
+ } : {}),
591
+ ...(user.phone ? {
592
+ phone: user.phone
593
+ } : {})
594
+ }
595
+ }));
596
+ return;
597
+ }
598
+ await this.establishSession(user, epoch);
599
+ });
600
+ }
601
+ async updateUser(attributes) {
602
+ this.requireBootConfig();
603
+ if (!this.state.booted) await this.bootPromise;
604
+ const customAttributes = sanitizeAttributes(attributes);
605
+ if (Object.keys(customAttributes).length === 0) return;
606
+ await this.client.identify({
607
+ customAttributes
608
+ });
609
+ }
610
+
611
+ /**
612
+ * Signs the visitor out for good — hosts should await this on sign-out.
613
+ * Synchronously stops realtime, re-init and the UI; then (queued behind any
614
+ * in-flight init) wipes the session and tells the server.
615
+ */
616
+ async logout() {
617
+ this.loggedOut = true;
618
+ this.currentUser = null;
619
+ this.pendingConversationId = null;
620
+ this.realtime.close();
621
+ this.clearTypingTimer();
622
+ this.resetThreadTracking();
623
+ this.appStateSubscription?.remove();
624
+ this.appStateSubscription = null;
625
+ this.setState({
626
+ ...INITIAL_STATE
627
+ });
628
+ await this.enqueueIdentity(() => this.wipeSession());
629
+ }
630
+
631
+ /**
632
+ * Full local + server sign-out of the stored session, used by logout() and
633
+ * by a login that switches to a different user. The local wipe runs first,
634
+ * so nothing of the old user stays readable even if the network hangs;
635
+ * then the push registration is removed and the token revoked — both
636
+ * authenticated with the captured token, time-boxed, and never re-initing.
637
+ * Only the cached channel theme survives (it carries no visitor data).
638
+ */
639
+ async wipeSession() {
640
+ this.wiping = true;
641
+ try {
642
+ await this.wipeSessionUnguarded();
643
+ } finally {
644
+ this.wiping = false;
645
+ }
646
+ }
647
+ async wipeSessionUnguarded() {
648
+ this.realtime.close();
649
+ this.clearTypingTimer();
650
+ this.resetThreadTracking();
651
+ this.appStateSubscription?.remove();
652
+ this.appStateSubscription = null;
653
+ this.currentUserKey = null;
654
+ this.userKeyLoaded = true;
655
+ this.pendingAgentByConversation = {};
656
+ this.seenMessageIds = [];
657
+ this.visitorTypingActive = false;
658
+ this.lastTypingPingAt = 0;
659
+ this.setState({
660
+ ...USER_SCOPED_STATE
661
+ });
662
+ const token = await this.client.getToken();
663
+ await Promise.all([this.client.clearToken(), SecureStorage.removeItem(USER_KEY), Storage.removeItem(UNREAD_KEY),
664
+ // A new anonymous visitor on this device gets a new tracking identity.
665
+ rotateDeviceId()]);
666
+
667
+ // Push DELETE first — it authenticates with the token about to be revoked.
668
+ await settleWithin(this.push.unregister(token), LOGOUT_REQUEST_TIMEOUT_MS);
669
+ if (token) {
670
+ await settleWithin(this.client.logout(token).catch(error => {
671
+ console.warn('[repzo-widget] server logout failed', error);
672
+ }), LOGOUT_REQUEST_TIMEOUT_MS);
673
+ }
674
+ }
675
+
676
+ /**
677
+ * Re-runs the last boot after a failure — wired to the error screen's retry
678
+ * and to foreground resume. Never rejects: the outcome lands in state.
679
+ */
680
+ async retry() {
681
+ if (!this.bootConfig || this.bootPromise || this.identityOpsInFlight > 0 || this.loggedOut) {
682
+ return;
683
+ }
684
+ try {
685
+ await this.boot({
686
+ ...this.bootConfig,
687
+ user: this.currentUser ?? undefined
688
+ });
689
+ } catch {
690
+ // Already surfaced through `error` / `errorCode`.
691
+ }
692
+ }
693
+
694
+ /** Drops every per-thread tracker — the visitor (or their session) changed. */
695
+ resetThreadTracking() {
696
+ this.threadGeneration += 1;
697
+ this.conversationCreation = null;
698
+ this.draftEvents = [];
699
+ this.cancelMarkRead();
700
+ }
701
+ requireBootConfig() {
702
+ if (!this.bootConfig) {
703
+ throw new RepzoWidgetError('RepzoWidget.boot() must be called first', 0, 'http');
704
+ }
705
+ }
706
+
707
+ // ─── Realtime ───────────────────────────────────────────────────────────
708
+
709
+ connectRealtime(token) {
710
+ if (!this.bootConfig || this.loggedOut || this.terminalErrorCode) return;
711
+ // Whoever connects explicitly (init, foreground) refreshes on its own;
712
+ // only the stream's backoff reconnects need the catch-up.
713
+ this.streamConnectedOnce = false;
714
+ this.streamErrored = false;
715
+ this.realtime.connect(this.bootConfig.baseUrl, token, (event, data) => {
716
+ this.handleRealtimeEvent(event, data);
717
+ }, {
718
+ onUnauthorized: failedToken => {
719
+ void this.reauthorizeStream(failedToken);
720
+ },
721
+ onForbidden: error => this.handleTerminalError(error)
722
+ });
723
+ }
724
+
725
+ /** The stream got a 401: share the single-flight re-init, then reconnect. */
726
+ async reauthorizeStream(failedToken) {
727
+ const now = Date.now();
728
+ if (now - this.lastStreamReauthAt < STREAM_REAUTH_MIN_INTERVAL_MS) return;
729
+ this.lastStreamReauthAt = now;
730
+ const epoch = this.epoch;
731
+ const token = await this.reauthorize(failedToken);
732
+ // A fresh init already reconnected; this covers a token another caller
733
+ // minted just before the stream noticed.
734
+ if (token && !this.isStale(epoch) && !this.realtime.isConnected) {
735
+ this.connectRealtime(token);
736
+ }
737
+ }
738
+ watchAppState() {
739
+ if (this.appStateSubscription) return;
740
+ this.appStateSubscription = AppState.addEventListener('change', status => {
741
+ if (status === 'active') {
742
+ void this.resumeFromBackground();
743
+ } else if (status === 'background') {
744
+ this.realtime.close();
745
+ }
746
+ });
747
+ }
748
+ async resumeFromBackground() {
749
+ if (this.loggedOut || this.terminalErrorCode) return;
750
+ if (!this.state.booted) {
751
+ // A boot that failed (offline at launch, say) retries once the visitor
752
+ // is back — Retry stays available too.
753
+ if (this.state.error) await this.retry();
754
+ return;
755
+ }
756
+ const epoch = this.epoch;
757
+ const token = await this.client.getToken();
758
+ if (!token || this.isStale(epoch)) return;
759
+ this.connectRealtime(token);
760
+ // The stream had a gap: refresh the list so unread/snippets are correct,
761
+ // and the thread on screen so replies sent meanwhile show up.
762
+ await Promise.all([this.refreshConversations(), this.refreshOpenThread()]);
763
+ await this.refreshConfig();
764
+ }
765
+
766
+ /**
767
+ * Re-read the config so availability and copy do not go stale. `isOnline` is
768
+ * otherwise a snapshot from boot, which a long-lived app would keep showing
769
+ * hours after the team went offline.
770
+ */
771
+ async refreshConfig() {
772
+ if (!this.state.booted) return;
773
+ const epoch = this.epoch;
774
+ try {
775
+ const fresh = await this.client.getConfig(sessionPlatform());
776
+ if (!fresh || this.isStale(epoch) || !this.state.config) return;
777
+ // Only availability is refreshed here. The public config endpoint knows
778
+ // nothing about the visitor (identified vs anonymous spaces, launcher
779
+ // rules), so replacing the whole session config with it — as this used
780
+ // to — swapped the mobile design for the web one until the next init.
781
+ const config = {
782
+ ...this.state.config,
783
+ isOnline: fresh.isOnline,
784
+ ...(typeof fresh.offlineMessage === 'string' ? {
785
+ offlineMessage: fresh.offlineMessage
786
+ } : {})
787
+ };
788
+ this.setState({
789
+ config
790
+ });
791
+ this.persistConfig(config);
792
+ } catch {
793
+ // Availability stays as-is; it refreshes again on the next foreground.
794
+ }
795
+ }
796
+ handleRealtimeEvent(event, data) {
797
+ if ((event === 'message.new' || event === 'conversation.event') && this.shouldHoldForDraft(data?.conversationId)) {
798
+ this.draftEvents.push({
799
+ event,
800
+ data
801
+ });
802
+ return;
803
+ }
804
+ switch (event) {
805
+ case 'connected':
806
+ // The stream reconnected after a drop: anything sent while it
807
+ // was down never streamed, so re-read the list and the open thread.
808
+ if (this.streamConnectedOnce || this.streamErrored) {
809
+ void this.refreshConversations();
810
+ void this.refreshOpenThread();
811
+ }
812
+ this.streamConnectedOnce = true;
813
+ this.streamErrored = false;
814
+ break;
815
+ case 'error':
816
+ this.streamErrored = true;
817
+ break;
818
+ case 'message.new':
819
+ this.handleNewMessage(data);
820
+ break;
821
+ case 'conversation.updated':
822
+ this.rememberConversationAgent(data);
823
+ this.setState(current => ({
824
+ conversations: current.conversations.map(conversation => conversation.id === data?.conversationId ? {
825
+ ...conversation,
826
+ agentName: data.agentName === undefined ? conversation.agentName : data.agentName,
827
+ agentAvatar: data.agentAvatar === undefined ? conversation.agentAvatar : this.client.resolveUrl(data.agentAvatar)
828
+ } : conversation)
829
+ }));
830
+ break;
831
+ case 'conversation.event':
832
+ this.handleConversationEvent(data);
833
+ break;
834
+ case 'typing.start':
835
+ this.rememberConversationAgent(data);
836
+ if (this.isThreadOnScreen(data?.conversationId)) {
837
+ this.setState(current => ({
838
+ agentTyping: true,
839
+ conversations: current.conversations.map(conversation => conversation.id === data.conversationId ? this.applyConversationAgent(conversation) : conversation)
840
+ }));
841
+ this.scheduleTypingClear(TYPING_CLEAR_MS);
842
+ }
843
+ break;
844
+ case 'typing.stop':
845
+ if (this.isThreadOnScreen(data?.conversationId)) {
846
+ this.scheduleTypingClear(TYPING_SETTLE_MS);
847
+ }
848
+ break;
849
+ default:
850
+ break;
851
+ }
852
+ }
853
+
854
+ /**
855
+ * The thread the visitor is looking at right now — the only one live
856
+ * messages, typing and events may paint into. The `isOpen` guard matters on
857
+ * mobile: the widget spends most of its life closed, and a stale
858
+ * `view === 'chat'` would otherwise swallow the unread increment. A blank
859
+ * draft matches nothing; its thread is adopted once the first send answers.
860
+ */
861
+ isThreadOnScreen(conversationId) {
862
+ if (!conversationId) return false;
863
+ return this.state.isOpen && this.state.view === 'chat' && this.state.activeConversationId === conversationId;
864
+ }
865
+
866
+ /**
867
+ * While a draft's first send is in flight its conversation id is unknown,
868
+ * and the server may already stream the thread's first reply (or an "agent
869
+ * joined" event). Events for a thread this device doesn't know are held
870
+ * until the send answers, then replayed: into the draft if it is that
871
+ * thread, otherwise as ordinary unread traffic.
872
+ */
873
+ shouldHoldForDraft(conversationId) {
874
+ const creation = this.conversationCreation;
875
+ return !!conversationId && !!creation && creation.generation === this.threadGeneration && !this.state.activeConversationId && !this.state.conversations.some(conversation => conversation.id === conversationId) && this.draftEvents.length < DRAFT_EVENT_BUFFER_LIMIT;
876
+ }
877
+ replayDraftEvents() {
878
+ const held = this.draftEvents;
879
+ this.draftEvents = [];
880
+ held.forEach(({
881
+ event,
882
+ data
883
+ }) => this.handleRealtimeEvent(event, data));
884
+ }
885
+ persistUnread(map) {
886
+ void Storage.setItem(UNREAD_KEY, JSON.stringify(map));
887
+ }
888
+
889
+ /** Survives a relaunch so a reply received overnight is still marked unread. */
890
+ async loadUnread() {
891
+ const raw = await Storage.getItem(UNREAD_KEY);
892
+ if (!raw) return {};
893
+ try {
894
+ const parsed = JSON.parse(raw);
895
+ if (!parsed || typeof parsed !== 'object') return {};
896
+ const result = {};
897
+ for (const [id, count] of Object.entries(parsed)) {
898
+ if (typeof count === 'number' && count > 0) result[id] = count;
899
+ }
900
+ return result;
901
+ } catch {
902
+ return {};
903
+ }
904
+ }
905
+ clearUnreadFor(conversationId) {
906
+ if (!this.state.unreadByConversation[conversationId]) {
907
+ return this.state.unreadByConversation;
908
+ }
909
+ const next = {
910
+ ...this.state.unreadByConversation
911
+ };
912
+ delete next[conversationId];
913
+ this.persistUnread(next);
914
+ return next;
915
+ }
916
+
917
+ // Real server ids only: the synthetic `sse-<now>` fallback is never a replay.
918
+ isReplayedMessage(messageId) {
919
+ if (!messageId) return false;
920
+ if (this.seenMessageIds.includes(messageId)) return true;
921
+ this.seenMessageIds.push(messageId);
922
+ if (this.seenMessageIds.length > SEEN_MESSAGE_LIMIT) this.seenMessageIds.shift();
923
+ return false;
924
+ }
925
+ handleNewMessage(data) {
926
+ if (this.isReplayedMessage(data?.messageId)) return;
927
+
928
+ // The server id is what history pages and catch-up fetches carry, so it
929
+ // is what dedupes; the synthetic id only covers an older server.
930
+ const messageId = data?.messageId || `sse-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
931
+ const message = {
932
+ id: messageId,
933
+ messageType: data?.messageType || 'message',
934
+ direction: data?.direction || 'outbound',
935
+ senderType: data?.senderType || 'agent',
936
+ senderName: data?.senderName || null,
937
+ body: data?.body || null,
938
+ bodyPlain: data?.bodyPlain || null,
939
+ attachments: this.hydrateAttachments(data?.attachments, messageId),
940
+ metadata: data?.metadata || null,
941
+ status: 'delivered',
942
+ createdAt: data?.createdAt || new Date().toISOString()
943
+ };
944
+ const clearsTyping = message.direction === 'outbound' && message.senderType !== 'visitor';
945
+ if (clearsTyping) this.clearTypingTimer();
946
+ const conversationId = data?.conversationId;
947
+ // Only real replies from the team count: never the visitor's own echo,
948
+ // never system rows like "agent joined" (the server counts the same way).
949
+ const isTeamReply = message.messageType === 'message' && message.direction !== 'inbound' && message.senderType !== 'visitor';
950
+ const onScreen = this.isThreadOnScreen(conversationId);
951
+
952
+ // Set inside the updater, read after: `setState` runs synchronously.
953
+ let countedAsUnread = false;
954
+ this.setState(current => {
955
+ const conversations = conversationId ? current.conversations.map(conversation => conversation.id === conversationId ? {
956
+ ...conversation,
957
+ snippet: message.messageType === 'message' ? message.bodyPlain || conversation.snippet : conversation.snippet,
958
+ lastMessageAt: message.createdAt,
959
+ messageCount: conversation.messageCount + 1
960
+ } : conversation) : current.conversations;
961
+ if (onScreen) {
962
+ const alreadyPresent = current.messages.some(existing => existing.id === message.id);
963
+ return {
964
+ conversations,
965
+ messages: alreadyPresent ? current.messages : [...current.messages, message],
966
+ agentTyping: clearsTyping ? false : current.agentTyping
967
+ };
968
+ }
969
+ if (!isTeamReply || !conversationId) return {
970
+ conversations
971
+ };
972
+ const unreadByConversation = {
973
+ ...current.unreadByConversation,
974
+ [conversationId]: (current.unreadByConversation[conversationId] ?? 0) + 1
975
+ };
976
+ this.persistUnread(unreadByConversation);
977
+ countedAsUnread = true;
978
+ return {
979
+ conversations,
980
+ unreadByConversation
981
+ };
982
+ });
983
+
984
+ // The visitor is reading it live — move the server cursor too, or the
985
+ // next list/init would badge a reply they already saw.
986
+ if (onScreen && isTeamReply && conversationId) this.scheduleMarkRead(conversationId);
987
+
988
+ // Emitted after the state write so a listener reading the unread count sees
989
+ // the new value. Only unread-counting replies fire it, which keeps the event
990
+ // in step with the badge: never the visitor's own echo, never a system row,
991
+ // never the open thread.
992
+ if (countedAsUnread && conversationId) {
993
+ this.events.emit(RepzoWidgetEvents.MessageDidArrive, {
994
+ conversationId,
995
+ message,
996
+ conversation: this.state.conversations.find(item => item.id === conversationId) ?? null
997
+ });
998
+ }
999
+
1000
+ // A reply can arrive for a conversation this device has never listed.
1001
+ if (conversationId && !this.state.conversations.some(item => item.id === conversationId)) {
1002
+ void this.refreshConversations();
1003
+ }
1004
+ }
1005
+
1006
+ /** Trailing debounce: one read call per burst of live replies. */
1007
+ scheduleMarkRead(conversationId) {
1008
+ if (this.readTimer && this.readConversationId !== conversationId) this.flushMarkRead();
1009
+ this.readConversationId = conversationId;
1010
+ if (this.readTimer) clearTimeout(this.readTimer);
1011
+ this.readTimer = setTimeout(() => this.flushMarkRead(), MARK_READ_DEBOUNCE_MS);
1012
+ }
1013
+
1014
+ /** Best effort and never re-inits — a missed cursor move only costs a badge. */
1015
+ flushMarkRead() {
1016
+ const conversationId = this.readConversationId;
1017
+ this.cancelMarkRead();
1018
+ if (!conversationId || !this.state.booted || this.loggedOut || this.terminalErrorCode) return;
1019
+ this.client.markConversationRead(conversationId).catch(() => {});
1020
+ }
1021
+ cancelMarkRead() {
1022
+ if (this.readTimer) clearTimeout(this.readTimer);
1023
+ this.readTimer = null;
1024
+ this.readConversationId = null;
1025
+ }
1026
+ handleConversationEvent(data) {
1027
+ if (!data?.conversationId) return;
1028
+ const message = {
1029
+ id: data.eventId || `event-${data.conversationId}-${Date.now()}`,
1030
+ messageType: 'system',
1031
+ direction: 'outbound',
1032
+ senderType: 'system',
1033
+ senderName: null,
1034
+ body: null,
1035
+ bodyPlain: data.bodyPlain || 'Conversation updated',
1036
+ attachments: null,
1037
+ metadata: data.metadata || null,
1038
+ status: 'delivered',
1039
+ createdAt: data.createdAt || new Date().toISOString()
1040
+ };
1041
+ if (!this.isThreadOnScreen(data.conversationId)) return;
1042
+ this.setState(current => current.messages.some(existing => existing.id === message.id) ? {} : {
1043
+ messages: [...current.messages, message]
1044
+ });
1045
+ }
1046
+ rememberConversationAgent(data) {
1047
+ if (!data?.conversationId) return;
1048
+ if (data.agentName === undefined && data.agentAvatar === undefined) return;
1049
+ this.pendingAgentByConversation[data.conversationId] = {
1050
+ agentName: data.agentName ?? null,
1051
+ agentAvatar: this.client.resolveUrl(data.agentAvatar ?? null)
1052
+ };
1053
+ }
1054
+ applyConversationAgent(conversation) {
1055
+ const pending = this.pendingAgentByConversation[conversation.id];
1056
+ if (!pending) return conversation;
1057
+ return {
1058
+ ...conversation,
1059
+ agentName: pending.agentName ?? conversation.agentName,
1060
+ agentAvatar: pending.agentAvatar ?? conversation.agentAvatar
1061
+ };
1062
+ }
1063
+ hydrateConversation(conversation) {
1064
+ return this.applyConversationAgent({
1065
+ ...conversation,
1066
+ agentAvatar: this.client.resolveUrl(conversation.agentAvatar)
1067
+ });
1068
+ }
1069
+
1070
+ /**
1071
+ * Normalizes attachments from history pages and live payloads alike. Live
1072
+ * payloads may omit `id`/`size`, so the list still gets a stable key.
1073
+ */
1074
+ hydrateAttachments(attachments, messageId) {
1075
+ if (!Array.isArray(attachments) || attachments.length === 0) return null;
1076
+ const hydrated = attachments.filter(item => !!item && typeof item === 'object').map((attachment, index) => ({
1077
+ ...attachment,
1078
+ id: typeof attachment.id === 'string' || typeof attachment.id === 'number' ? String(attachment.id) : `${messageId ?? 'attachment'}-${index}`,
1079
+ name: typeof attachment.name === 'string' && attachment.name ? attachment.name : 'Attachment',
1080
+ contentType: typeof attachment.contentType === 'string' ? attachment.contentType : '',
1081
+ size: typeof attachment.size === 'number' ? attachment.size : 0,
1082
+ // Empty (never the raw value) when the URL isn't http(s) — the preview
1083
+ // then renders the name only, with nothing to open.
1084
+ url: this.client.resolveUrl(typeof attachment.url === 'string' ? attachment.url : null) ?? ''
1085
+ }));
1086
+ return hydrated.length > 0 ? hydrated : null;
1087
+ }
1088
+ clearTypingTimer() {
1089
+ if (this.typingTimer) {
1090
+ clearTimeout(this.typingTimer);
1091
+ this.typingTimer = null;
1092
+ }
1093
+ if (this.state.agentTyping) this.setState({
1094
+ agentTyping: false
1095
+ });
1096
+ }
1097
+ scheduleTypingClear(delayMs) {
1098
+ if (this.typingTimer) clearTimeout(this.typingTimer);
1099
+ this.typingTimer = setTimeout(() => {
1100
+ this.typingTimer = null;
1101
+ this.setState({
1102
+ agentTyping: false
1103
+ });
1104
+ }, delayMs);
1105
+ }
1106
+
1107
+ // ─── UI commands ────────────────────────────────────────────────────────
1108
+
1109
+ resolveOpenView(space) {
1110
+ const {
1111
+ config,
1112
+ conversations
1113
+ } = this.state;
1114
+ const launchDirectly = config?.launchDirectly ?? false;
1115
+ const enabled = (config?.spaces ?? []).filter(item => item.enabled);
1116
+ const target = space ?? (launchDirectly ? 'messages' : firstEnabledSpace(config?.spaces) ?? 'home');
1117
+ if (target === 'messages') {
1118
+ if (conversations.length > 0) return 'conversations';
1119
+ return config?.allowNewConversations === false ? 'conversations' : 'chat';
1120
+ }
1121
+ if (enabled.length > 0 && !enabled.some(item => item.type === 'home')) return 'conversations';
1122
+ return 'home';
1123
+ }
1124
+ present(space) {
1125
+ if (!this.bootConfig) {
1126
+ console.warn('[repzo-widget] present() ignored — RepzoWidget.boot() has not been called');
1127
+ return;
1128
+ }
1129
+ const view = this.resolveOpenView(space);
1130
+ // A fresh "start a conversation" landing must not reuse an old thread.
1131
+ if (view === 'chat') this.threadGeneration += 1;
1132
+ this.setState({
1133
+ isOpen: true,
1134
+ view,
1135
+ activeTab: view === 'home' ? 'home' : 'messages',
1136
+ ...(view === 'chat' ? {
1137
+ activeConversationId: null,
1138
+ messages: [],
1139
+ messagesLoading: false,
1140
+ hasMoreMessages: false,
1141
+ messageCursor: null
1142
+ } : {})
1143
+ });
1144
+ this.track('widget_opened');
1145
+ }
1146
+ hide() {
1147
+ if (!this.state.isOpen) return;
1148
+ this.setState({
1149
+ isOpen: false,
1150
+ view: 'bubble',
1151
+ agentTyping: false
1152
+ });
1153
+ this.clearTypingTimer();
1154
+ this.track('widget_closed');
1155
+ }
1156
+ toggle(space) {
1157
+ if (this.state.isOpen) this.hide();else this.present(space);
1158
+ }
1159
+ setView(view) {
1160
+ this.setState({
1161
+ view,
1162
+ ...(view === 'chat' || view === 'conversations' ? {
1163
+ activeTab: 'messages'
1164
+ } : {}),
1165
+ ...(view === 'home' ? {
1166
+ activeTab: 'home'
1167
+ } : {})
1168
+ });
1169
+ }
1170
+ setActiveTab(tab) {
1171
+ if (tab === 'home') {
1172
+ this.setState({
1173
+ activeTab: 'home',
1174
+ view: 'home'
1175
+ });
1176
+ return;
1177
+ }
1178
+ if (this.state.conversations.length > 0) {
1179
+ this.setState({
1180
+ activeTab: 'messages',
1181
+ view: 'conversations'
1182
+ });
1183
+ } else if (this.state.config?.allowNewConversations === false) {
1184
+ this.setState({
1185
+ activeTab: 'messages',
1186
+ view: 'conversations'
1187
+ });
1188
+ } else {
1189
+ this.startNewConversation();
1190
+ }
1191
+ }
1192
+ setLocale(locale) {
1193
+ this.setState({
1194
+ locale
1195
+ });
1196
+ }
1197
+ startNewConversation(prefill) {
1198
+ this.threadGeneration += 1;
1199
+ this.setState(current => ({
1200
+ isOpen: true,
1201
+ activeTab: 'messages',
1202
+ activeConversationId: null,
1203
+ messages: [],
1204
+ // A load still in flight for the previous thread is dropped on arrival.
1205
+ messagesLoading: false,
1206
+ hasMoreMessages: false,
1207
+ messageCursor: null,
1208
+ agentTyping: false,
1209
+ view: current.config?.preChatEnabled ? 'prechat' : 'chat',
1210
+ prefillText: prefill ?? ''
1211
+ }));
1212
+ }
1213
+ setPrefillText(prefill) {
1214
+ this.setState({
1215
+ prefillText: prefill
1216
+ });
1217
+ }
1218
+ dismissOutboundMessage() {
1219
+ const outbound = this.state.outboundMessage;
1220
+ if (outbound?.id) this.track('widget_outbound_dismissed', {
1221
+ outboundId: outbound.id
1222
+ });
1223
+ this.setState({
1224
+ outboundMessage: null
1225
+ });
1226
+ }
1227
+
1228
+ // ─── Conversations & messages ───────────────────────────────────────────
1229
+
1230
+ async refreshConversations() {
1231
+ if (!this.state.booted) return;
1232
+ const epoch = this.epoch;
1233
+ try {
1234
+ const result = await this.client.listConversations();
1235
+ // Never repaint a signed-out (or switched) visitor's list.
1236
+ if (this.isStale(epoch)) return;
1237
+ this.setState(current => {
1238
+ // Reconcile unread with the server so replies that arrived during a
1239
+ // background gap still count (same rule as the boot-time seeding).
1240
+ const unreadByConversation = {};
1241
+ for (const conversation of result.data) {
1242
+ const count = typeof conversation.unreadCount === 'number' ? conversation.unreadCount : current.unreadByConversation[conversation.id] ?? 0;
1243
+ if (count > 0) unreadByConversation[conversation.id] = count;
1244
+ }
1245
+ // The thread on screen is being read right now — never re-badge it.
1246
+ if (current.activeConversationId && this.isThreadOnScreen(current.activeConversationId)) {
1247
+ delete unreadByConversation[current.activeConversationId];
1248
+ }
1249
+ this.persistUnread(unreadByConversation);
1250
+ return {
1251
+ conversations: result.data.map(conversation => this.hydrateConversation(conversation)),
1252
+ unreadByConversation
1253
+ };
1254
+ });
1255
+ } catch {
1256
+ // Keep whatever we already have — the list refreshes again on next open.
1257
+ }
1258
+ }
1259
+ async openConversation(conversationId) {
1260
+ this.threadGeneration += 1;
1261
+ if (!this.state.booted) {
1262
+ this.pendingConversationId = conversationId;
1263
+ this.setState({
1264
+ isOpen: true,
1265
+ activeTab: 'messages'
1266
+ });
1267
+ return;
1268
+ }
1269
+ const generation = this.threadGeneration;
1270
+ const unreadByConversation = this.clearUnreadFor(conversationId);
1271
+ this.setState({
1272
+ isOpen: true,
1273
+ activeTab: 'messages',
1274
+ activeConversationId: conversationId,
1275
+ messages: [],
1276
+ messagesLoading: true,
1277
+ hasMoreMessages: false,
1278
+ messageCursor: null,
1279
+ view: 'chat',
1280
+ unreadByConversation,
1281
+ agentTyping: false
1282
+ });
1283
+ // Loading the thread advances the server-side read cursor; hosts listen
1284
+ // to this to dismiss the conversation's push notification from the tray.
1285
+ this.events.emit(RepzoWidgetEvents.ConversationDidRead, {
1286
+ conversationId
1287
+ });
1288
+ const epoch = this.epoch;
1289
+ try {
1290
+ const result = await this.client.listMessages(conversationId);
1291
+ // Opening A then B quickly: A's page must never land under B.
1292
+ if (this.isStale(epoch) || this.threadGeneration !== generation) return;
1293
+ this.setState(current => {
1294
+ // Live replies and optimistic sends may have arrived since this page
1295
+ // was requested. Keep them, preferring the live copy for shared ids.
1296
+ const liveIds = new Set(current.messages.map(message => message.id));
1297
+ const history = this.hydrateMessages(result.data).filter(message => !liveIds.has(message.id));
1298
+ return {
1299
+ messages: sortByCreatedAt([...history, ...current.messages]),
1300
+ messagesLoading: false,
1301
+ hasMoreMessages: result.meta.hasMore,
1302
+ messageCursor: result.meta.nextCursor
1303
+ };
1304
+ });
1305
+ } catch {
1306
+ if (!this.isStale(epoch) && this.threadGeneration === generation) {
1307
+ this.setState({
1308
+ messagesLoading: false
1309
+ });
1310
+ }
1311
+ }
1312
+ }
1313
+ async loadMoreMessages() {
1314
+ const {
1315
+ activeConversationId,
1316
+ hasMoreMessages,
1317
+ messagesLoading,
1318
+ messageCursor
1319
+ } = this.state;
1320
+ if (!activeConversationId || !hasMoreMessages || messagesLoading || !messageCursor) return;
1321
+ this.setState({
1322
+ messagesLoading: true
1323
+ });
1324
+ const epoch = this.epoch;
1325
+ const generation = this.threadGeneration;
1326
+ const isCurrent = () => !this.isStale(epoch) && this.threadGeneration === generation && this.state.activeConversationId === activeConversationId;
1327
+ try {
1328
+ const result = await this.client.listMessages(activeConversationId, messageCursor);
1329
+ // An older page for a thread that is no longer on screen goes nowhere.
1330
+ if (!isCurrent()) return;
1331
+ this.setState(current => {
1332
+ const known = new Set(current.messages.map(message => message.id));
1333
+ const older = this.hydrateMessages(result.data).filter(message => !known.has(message.id));
1334
+ return {
1335
+ messages: [...older, ...current.messages],
1336
+ messagesLoading: false,
1337
+ hasMoreMessages: result.meta.hasMore,
1338
+ messageCursor: result.meta.nextCursor
1339
+ };
1340
+ });
1341
+ } catch {
1342
+ if (isCurrent()) this.setState({
1343
+ messagesLoading: false
1344
+ });
1345
+ }
1346
+ }
1347
+
1348
+ /**
1349
+ * Re-reads the newest page of the thread on screen and merges in whatever
1350
+ * the stream missed (reconnect, foreground, re-init). Deduped by server id.
1351
+ * Only for a thread actually on screen: the first page also advances the
1352
+ * server read cursor, which must not happen for a thread nobody is reading.
1353
+ */
1354
+ async refreshOpenThread() {
1355
+ const conversationId = this.state.activeConversationId;
1356
+ if (!this.state.booted || !conversationId || !this.isThreadOnScreen(conversationId)) return;
1357
+ // A load already in flight owns the list (and its cursor) — let it land.
1358
+ if (this.state.messagesLoading) return;
1359
+ const epoch = this.epoch;
1360
+ const generation = this.threadGeneration;
1361
+ try {
1362
+ const result = await this.client.listMessages(conversationId);
1363
+ if (this.isStale(epoch) || this.threadGeneration !== generation) return;
1364
+ if (this.state.activeConversationId !== conversationId) return;
1365
+ const fresh = this.hydrateMessages(result.data);
1366
+ this.setState(current => {
1367
+ const known = new Set(current.messages.map(message => message.id));
1368
+ const missing = fresh.filter(message => !known.has(message.id));
1369
+ if (missing.length === 0) return {};
1370
+ const local = current.messages.filter(isLocalMessage);
1371
+ const hasServerMessages = local.length < current.messages.length;
1372
+ // No overlap means the gap is longer than a page: restart from the
1373
+ // newest page (keeping unsent bubbles) rather than leave a hole.
1374
+ if (missing.length === fresh.length || !hasServerMessages) {
1375
+ return {
1376
+ messages: [...fresh, ...local],
1377
+ hasMoreMessages: result.meta.hasMore,
1378
+ messageCursor: result.meta.nextCursor
1379
+ };
1380
+ }
1381
+ return {
1382
+ messages: sortByCreatedAt([...current.messages, ...missing])
1383
+ };
1384
+ });
1385
+ // The server just moved the read cursor — mirror it locally.
1386
+ if (this.state.unreadByConversation[conversationId]) {
1387
+ this.setState({
1388
+ unreadByConversation: this.clearUnreadFor(conversationId)
1389
+ });
1390
+ }
1391
+ } catch {
1392
+ // The next reconnect or foreground tries again.
1393
+ }
1394
+ }
1395
+
1396
+ /** A history page (newest-first) as the oldest-first list the UI renders. */
1397
+ hydrateMessages(page) {
1398
+ return page.map(message => ({
1399
+ ...message,
1400
+ attachments: this.hydrateAttachments(message.attachments, message.id)
1401
+ })).reverse();
1402
+ }
1403
+ async sendMessage(body, attachments = [], agentUiAction) {
1404
+ const trimmed = body.trim();
1405
+ if (!trimmed && attachments.length === 0) return;
1406
+ const {
1407
+ visitorProfile
1408
+ } = this.state;
1409
+ const generation = this.threadGeneration;
1410
+ const tempId = `temp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1411
+ const optimistic = {
1412
+ id: tempId,
1413
+ messageType: 'message',
1414
+ direction: 'inbound',
1415
+ senderType: 'visitor',
1416
+ senderName: visitorProfile.name || 'You',
1417
+ body: trimmed ? `<p>${trimmed}</p>` : '',
1418
+ bodyPlain: trimmed,
1419
+ attachments: attachments.length > 0 ? attachments : null,
1420
+ metadata: null,
1421
+ status: 'pending',
1422
+ createdAt: new Date().toISOString()
1423
+ };
1424
+ this.setState(current => ({
1425
+ messages: [...current.messages, optimistic],
1426
+ prefillText: ''
1427
+ }));
1428
+ this.track('widget_message_sent', {
1429
+ hasText: !!trimmed,
1430
+ attachmentCount: attachments.length
1431
+ });
1432
+ const markFailed = () => this.setState(current => ({
1433
+ messages: current.messages.map(message => message.id === tempId ? {
1434
+ ...message,
1435
+ status: 'failed'
1436
+ } : message)
1437
+ }));
1438
+ const epoch = this.epoch;
1439
+
1440
+ // A draft's first send creates the conversation. Anything sent from the
1441
+ // same draft before that POST answers waits for its id rather than
1442
+ // creating a second thread; the bubble is already on screen meanwhile.
1443
+ let conversationId = this.state.activeConversationId;
1444
+ let ownCreation = null;
1445
+ while (!conversationId) {
1446
+ const creation = this.conversationCreation;
1447
+ if (!creation || creation.generation !== generation) {
1448
+ ownCreation = createConversationCreation(generation);
1449
+ this.conversationCreation = ownCreation;
1450
+ break;
1451
+ }
1452
+ conversationId = await creation.promise;
1453
+ if (this.isStale(epoch)) return;
1454
+ // The creating send failed and the visitor has since left the draft:
1455
+ // don't open a thread nobody is looking at.
1456
+ if (!conversationId && this.threadGeneration !== generation) {
1457
+ markFailed();
1458
+ return;
1459
+ }
1460
+ }
1461
+ let createdId = null;
1462
+ try {
1463
+ const result = await this.client.sendMessage({
1464
+ conversationId: conversationId || undefined,
1465
+ forceNewConversation: !conversationId,
1466
+ body: trimmed,
1467
+ attachments: attachments.length > 0 ? attachments : undefined,
1468
+ visitorName: visitorProfile.name,
1469
+ visitorEmail: visitorProfile.email,
1470
+ agentUiAction
1471
+ });
1472
+ if (this.isStale(epoch)) return;
1473
+ if (ownCreation) createdId = result.conversationId;
1474
+ this.setState(current => {
1475
+ // The stream or a catch-up fetch may have delivered this message
1476
+ // already — keep one copy, never two bubbles with the same id.
1477
+ const delivered = current.messages.some(message => message.id === result.messageId);
1478
+ const messages = delivered ? current.messages.filter(message => message.id !== tempId) : current.messages.map(message => message.id === tempId ? {
1479
+ ...message,
1480
+ id: result.messageId,
1481
+ status: 'sent'
1482
+ } : message);
1483
+ if (!createdId) return {
1484
+ messages
1485
+ };
1486
+ const known = current.conversations.some(item => item.id === result.conversationId);
1487
+ const conversation = {
1488
+ id: result.conversationId,
1489
+ subject: null,
1490
+ status: 'open',
1491
+ snippet: trimmed ? trimmed.slice(0, 100) : `${attachments.length} attachment${attachments.length === 1 ? '' : 's'}`,
1492
+ messageCount: 1,
1493
+ lastMessageAt: new Date().toISOString(),
1494
+ createdAt: new Date().toISOString(),
1495
+ agentName: null,
1496
+ agentAvatar: null
1497
+ };
1498
+ return {
1499
+ messages,
1500
+ conversations: known ? current.conversations : [this.applyConversationAgent(conversation), ...current.conversations],
1501
+ // Only adopt the thread if the visitor is still on this draft.
1502
+ ...(this.threadGeneration === generation ? {
1503
+ activeConversationId: result.conversationId
1504
+ } : {})
1505
+ };
1506
+ });
1507
+ } catch (error) {
1508
+ if (this.isStale(epoch)) throw error;
1509
+ markFailed();
1510
+ throw error;
1511
+ } finally {
1512
+ if (ownCreation) {
1513
+ if (this.conversationCreation === ownCreation) this.conversationCreation = null;
1514
+ // Resolves null on failure: the next queued send then creates it.
1515
+ ownCreation.resolve(createdId);
1516
+ // Anything held back for the draft now has a home (or goes to unread).
1517
+ this.replayDraftEvents();
1518
+ }
1519
+ }
1520
+ }
1521
+ async identifyVisitor(data) {
1522
+ this.setState(current => ({
1523
+ visitorProfile: {
1524
+ ...current.visitorProfile,
1525
+ ...(data.name ? {
1526
+ name: data.name
1527
+ } : {}),
1528
+ ...(data.email ? {
1529
+ email: data.email
1530
+ } : {}),
1531
+ ...(data.phone ? {
1532
+ phone: data.phone
1533
+ } : {})
1534
+ }
1535
+ }));
1536
+ await this.client.identify(data);
1537
+ }
1538
+
1539
+ /** Fire-and-forget visitor typing ping, throttled well under the 30/min cap. */
1540
+ notifyTyping(isTyping) {
1541
+ const conversationId = this.state.activeConversationId;
1542
+ if (!conversationId) return;
1543
+ const now = Date.now();
1544
+ if (isTyping) {
1545
+ if (now - this.lastTypingPingAt < TYPING_PING_INTERVAL_MS) return;
1546
+ this.lastTypingPingAt = now;
1547
+ } else {
1548
+ // Nothing to stop — skip the round trip rather than burn rate limit.
1549
+ if (!this.visitorTypingActive) return;
1550
+ this.lastTypingPingAt = 0;
1551
+ }
1552
+ this.visitorTypingActive = isTyping;
1553
+ this.client.sendTyping(conversationId, isTyping).catch(() => {});
1554
+ }
1555
+
1556
+ // ─── Analytics & outbound ───────────────────────────────────────────────
1557
+
1558
+ track(event, metadata = {}) {
1559
+ if (!this.state.booted || this.state.config?.analyticsEnabled === false) return;
1560
+ this.client.trackEvent({
1561
+ event,
1562
+ visitorId: this.state.identityId,
1563
+ metadata: {
1564
+ locale: this.state.locale,
1565
+ platform: Platform.OS,
1566
+ ...metadata
1567
+ }
1568
+ }).catch(() => {});
1569
+ }
1570
+ loadOutboundMessage() {
1571
+ if (this.state.outboundMessage) return;
1572
+ const epoch = this.epoch;
1573
+ this.client.getOutboundMessage().then(({
1574
+ message
1575
+ }) => {
1576
+ if (!message || this.isStale(epoch)) return;
1577
+ this.setState({
1578
+ outboundMessage: {
1579
+ ...message,
1580
+ imageUrl: this.client.resolveUrl(message.imageUrl) ?? undefined
1581
+ }
1582
+ });
1583
+ }).catch(() => {});
1584
+ }
1585
+
1586
+ // ─── Teardown (tests / hot reload) ──────────────────────────────────────
1587
+
1588
+ destroy() {
1589
+ this.realtime.close();
1590
+ this.clearTypingTimer();
1591
+ this.resetThreadTracking();
1592
+ this.appStateSubscription?.remove();
1593
+ this.appStateSubscription = null;
1594
+ this.subscribers.clear();
1595
+ this.events.removeAllListeners();
1596
+ }
1597
+ }
1598
+ function createConversationCreation(generation) {
1599
+ let resolve = () => {};
1600
+ const promise = new Promise(settle => {
1601
+ resolve = settle;
1602
+ });
1603
+ return {
1604
+ generation,
1605
+ promise,
1606
+ resolve
1607
+ };
1608
+ }
1609
+
1610
+ /** Optimistic visitor bubbles the server hasn't acknowledged (pending/failed). */
1611
+ function isLocalMessage(message) {
1612
+ return message.id.startsWith('temp-');
1613
+ }
1614
+
1615
+ /** Stable, so same-instant rows keep their order; bad dates sort as oldest. */
1616
+ function sortByCreatedAt(messages) {
1617
+ const time = message => Date.parse(message.createdAt) || 0;
1618
+ return [...messages].sort((a, b) => time(a) - time(b));
1619
+ }
1620
+ function identityKey(user) {
1621
+ return (user.email || user.userId || '').toLowerCase().trim();
1622
+ }
1623
+
1624
+ /**
1625
+ * The visitor token travels on every request, so plain http is refused except
1626
+ * for local development: localhost, the Android emulator's host alias,
1627
+ * `*.local` hosts, or any host in a `__DEV__` build.
1628
+ */
1629
+ function assertSecureBaseUrl(baseUrl) {
1630
+ const match = /^(https?):\/\/(\[[^\]]+\]|[^/?#:]+)(?::\d+)?(?:[/?#]|$)/i.exec(baseUrl);
1631
+ if (!match) {
1632
+ throw new RepzoWidgetError(`boot(): baseUrl must be an absolute https:// URL (got "${baseUrl}")`, 0, 'http');
1633
+ }
1634
+ const scheme = match[1].toLowerCase();
1635
+ const host = match[2].toLowerCase();
1636
+ if (scheme === 'https') return baseUrl;
1637
+ const isDevBuild = typeof __DEV__ !== 'undefined' && __DEV__;
1638
+ if (isDevBuild || LOCAL_DEV_HOSTS.has(host) || host.endsWith('.local')) return baseUrl;
1639
+ throw new RepzoWidgetError(`boot(): baseUrl must use https:// (got "${baseUrl}"). Plain http is only allowed for ` + 'localhost, 127.0.0.1, 10.0.2.2, *.local hosts, or in __DEV__ builds.', 0, 'http');
1640
+ }
1641
+
1642
+ /** Waits for `promise` at most `ms`; never rejects. */
1643
+ function settleWithin(promise, ms) {
1644
+ return new Promise(resolve => {
1645
+ const timer = setTimeout(resolve, ms);
1646
+ const done = () => {
1647
+ clearTimeout(timer);
1648
+ resolve();
1649
+ };
1650
+ promise.then(done, done);
1651
+ });
1652
+ }
1653
+ function firstEnabledSpace(spaces) {
1654
+ return spaces?.find(space => space.enabled)?.type ?? null;
1655
+ }
1656
+
1657
+ /** The server rejects oversized bags; drop junk here so a typo can't 400 a login. */
1658
+ function sanitizeAttributes(attributes) {
1659
+ const result = {};
1660
+ if (!attributes || typeof attributes !== 'object') return result;
1661
+ for (const [key, value] of Object.entries(attributes)) {
1662
+ if (!key || key.length > 64) continue;
1663
+ if (value === null || value === undefined) continue;
1664
+ if (typeof value === 'string') {
1665
+ if (!value.trim()) continue;
1666
+ result[key] = value.slice(0, 512);
1667
+ } else if (typeof value === 'number') {
1668
+ if (!Number.isFinite(value)) continue;
1669
+ result[key] = value;
1670
+ } else if (typeof value === 'boolean') {
1671
+ result[key] = value;
1672
+ }
1673
+ if (Object.keys(result).length >= 30) break;
1674
+ }
1675
+ return result;
1676
+ }
1677
+ //# sourceMappingURL=widget-store.js.map