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