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