@repzo/widget-react-native 1.3.0 → 1.4.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/CHANGELOG.md +179 -0
- package/README.md +72 -16
- package/lib/commonjs/api/client.js +113 -0
- package/lib/commonjs/api/client.js.map +1 -1
- package/lib/commonjs/attachments/file-size.js +52 -0
- package/lib/commonjs/attachments/file-size.js.map +1 -0
- package/lib/commonjs/attachments/local-previews.js +40 -0
- package/lib/commonjs/attachments/local-previews.js.map +1 -0
- package/lib/commonjs/attachments/pickers.js +76 -28
- package/lib/commonjs/attachments/pickers.js.map +1 -1
- package/lib/commonjs/i18n.js +24 -3
- package/lib/commonjs/i18n.js.map +1 -1
- package/lib/commonjs/locales.js +13 -3
- package/lib/commonjs/locales.js.map +1 -1
- package/lib/commonjs/state/widget-store.js +486 -63
- package/lib/commonjs/state/widget-store.js.map +1 -1
- package/lib/commonjs/storage.js +3 -1
- package/lib/commonjs/storage.js.map +1 -1
- package/lib/commonjs/types.js.map +1 -1
- package/lib/commonjs/ui/WidgetProvider.js +40 -16
- package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
- package/lib/commonjs/ui/components/AttachmentChips.js +109 -40
- package/lib/commonjs/ui/components/AttachmentChips.js.map +1 -1
- package/lib/commonjs/ui/components/ConversationRow.js +65 -10
- package/lib/commonjs/ui/components/ConversationRow.js.map +1 -1
- package/lib/commonjs/ui/components/ImageViewer.js +231 -25
- package/lib/commonjs/ui/components/ImageViewer.js.map +1 -1
- package/lib/commonjs/ui/components/RatingCard.js +5 -2
- package/lib/commonjs/ui/components/RatingCard.js.map +1 -1
- package/lib/commonjs/ui/components/ZoomableImage.js +58 -10
- package/lib/commonjs/ui/components/ZoomableImage.js.map +1 -1
- package/lib/commonjs/ui/components/icons.js +22 -16
- package/lib/commonjs/ui/components/icons.js.map +1 -1
- package/lib/commonjs/ui/hooks/use-retained-state.js +39 -0
- package/lib/commonjs/ui/hooks/use-retained-state.js.map +1 -0
- package/lib/commonjs/ui/screens/chat/ChatView.js +666 -171
- package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js +150 -3
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
- package/lib/commonjs/ui/screens/conversations/ConversationsList.js +1 -0
- package/lib/commonjs/ui/screens/conversations/ConversationsList.js.map +1 -1
- package/lib/commonjs/ui/screens/home/HomeView.js +38 -55
- package/lib/commonjs/ui/screens/home/HomeView.js.map +1 -1
- package/lib/commonjs/ui/screens/home/HomeView.styles.js +36 -45
- package/lib/commonjs/ui/screens/home/HomeView.styles.js.map +1 -1
- package/lib/commonjs/ui/styles/theme.js +1 -0
- package/lib/commonjs/ui/styles/theme.js.map +1 -1
- package/lib/commonjs/ui/utils/conversations.js +17 -1
- package/lib/commonjs/ui/utils/conversations.js.map +1 -1
- package/lib/commonjs/ui/utils/html.js +13 -0
- package/lib/commonjs/ui/utils/html.js.map +1 -1
- package/lib/commonjs/ui/utils/image-load-queue.js +114 -0
- package/lib/commonjs/ui/utils/image-load-queue.js.map +1 -0
- package/lib/commonjs/ui/utils/inline-attachments.js +88 -0
- package/lib/commonjs/ui/utils/inline-attachments.js.map +1 -0
- package/lib/module/api/client.js +113 -0
- package/lib/module/api/client.js.map +1 -1
- package/lib/module/attachments/file-size.js +47 -0
- package/lib/module/attachments/file-size.js.map +1 -0
- package/lib/module/attachments/local-previews.js +33 -0
- package/lib/module/attachments/local-previews.js.map +1 -0
- package/lib/module/attachments/pickers.js +74 -27
- package/lib/module/attachments/pickers.js.map +1 -1
- package/lib/module/i18n.js +24 -3
- package/lib/module/i18n.js.map +1 -1
- package/lib/module/locales.js +13 -3
- package/lib/module/locales.js.map +1 -1
- package/lib/module/state/widget-store.js +488 -65
- package/lib/module/state/widget-store.js.map +1 -1
- package/lib/module/storage.js +2 -0
- package/lib/module/storage.js.map +1 -1
- package/lib/module/types.js.map +1 -1
- package/lib/module/ui/WidgetProvider.js +40 -16
- package/lib/module/ui/WidgetProvider.js.map +1 -1
- package/lib/module/ui/components/AttachmentChips.js +111 -42
- package/lib/module/ui/components/AttachmentChips.js.map +1 -1
- package/lib/module/ui/components/ConversationRow.js +65 -10
- package/lib/module/ui/components/ConversationRow.js.map +1 -1
- package/lib/module/ui/components/ImageViewer.js +233 -27
- package/lib/module/ui/components/ImageViewer.js.map +1 -1
- package/lib/module/ui/components/RatingCard.js +5 -2
- package/lib/module/ui/components/RatingCard.js.map +1 -1
- package/lib/module/ui/components/ZoomableImage.js +58 -10
- package/lib/module/ui/components/ZoomableImage.js.map +1 -1
- package/lib/module/ui/components/icons.js +21 -15
- package/lib/module/ui/components/icons.js.map +1 -1
- package/lib/module/ui/hooks/use-retained-state.js +34 -0
- package/lib/module/ui/hooks/use-retained-state.js.map +1 -0
- package/lib/module/ui/screens/chat/ChatView.js +672 -177
- package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/module/ui/screens/chat/ChatView.styles.js +152 -4
- package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
- package/lib/module/ui/screens/conversations/ConversationsList.js +1 -0
- package/lib/module/ui/screens/conversations/ConversationsList.js.map +1 -1
- package/lib/module/ui/screens/home/HomeView.js +40 -57
- package/lib/module/ui/screens/home/HomeView.js.map +1 -1
- package/lib/module/ui/screens/home/HomeView.styles.js +36 -45
- package/lib/module/ui/screens/home/HomeView.styles.js.map +1 -1
- package/lib/module/ui/styles/theme.js +1 -0
- package/lib/module/ui/styles/theme.js.map +1 -1
- package/lib/module/ui/utils/conversations.js +15 -1
- package/lib/module/ui/utils/conversations.js.map +1 -1
- package/lib/module/ui/utils/html.js +12 -0
- package/lib/module/ui/utils/html.js.map +1 -1
- package/lib/module/ui/utils/image-load-queue.js +109 -0
- package/lib/module/ui/utils/image-load-queue.js.map +1 -0
- package/lib/module/ui/utils/inline-attachments.js +85 -0
- package/lib/module/ui/utils/inline-attachments.js.map +1 -0
- package/lib/typescript/commonjs/api/client.d.ts +35 -1
- package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
- package/lib/typescript/commonjs/attachments/file-size.d.ts +15 -0
- package/lib/typescript/commonjs/attachments/file-size.d.ts.map +1 -0
- package/lib/typescript/commonjs/attachments/local-previews.d.ts +14 -0
- package/lib/typescript/commonjs/attachments/local-previews.d.ts.map +1 -0
- package/lib/typescript/commonjs/attachments/pickers.d.ts +35 -10
- package/lib/typescript/commonjs/attachments/pickers.d.ts.map +1 -1
- package/lib/typescript/commonjs/i18n.d.ts +13 -3
- package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
- package/lib/typescript/commonjs/locales.d.ts.map +1 -1
- package/lib/typescript/commonjs/state/widget-store.d.ts +67 -4
- package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/commonjs/storage.d.ts +2 -0
- package/lib/typescript/commonjs/storage.d.ts.map +1 -1
- package/lib/typescript/commonjs/types.d.ts +58 -0
- package/lib/typescript/commonjs/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/AttachmentChips.d.ts +13 -3
- package/lib/typescript/commonjs/ui/components/AttachmentChips.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/ConversationRow.d.ts +15 -1
- package/lib/typescript/commonjs/ui/components/ConversationRow.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts +26 -6
- package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/RatingCard.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts +10 -1
- package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/icons.d.ts +2 -1
- package/lib/typescript/commonjs/ui/components/icons.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/use-retained-state.d.ts +11 -0
- package/lib/typescript/commonjs/ui/hooks/use-retained-state.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts +4 -4
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +154 -2
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +30 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/conversations/ConversationsList.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/conversations/ConversationsList.types.d.ts +2 -0
- package/lib/typescript/commonjs/ui/screens/conversations/ConversationsList.types.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/home/HomeView.d.ts +7 -5
- package/lib/typescript/commonjs/ui/screens/home/HomeView.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/home/HomeView.styles.d.ts +30 -35
- package/lib/typescript/commonjs/ui/screens/home/HomeView.styles.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/home/HomeView.types.d.ts +5 -6
- package/lib/typescript/commonjs/ui/screens/home/HomeView.types.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/styles/theme.d.ts +2 -0
- package/lib/typescript/commonjs/ui/styles/theme.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/conversations.d.ts +10 -2
- package/lib/typescript/commonjs/ui/utils/conversations.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/html.d.ts +5 -0
- package/lib/typescript/commonjs/ui/utils/html.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/image-load-queue.d.ts +44 -0
- package/lib/typescript/commonjs/ui/utils/image-load-queue.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/utils/inline-attachments.d.ts +29 -0
- package/lib/typescript/commonjs/ui/utils/inline-attachments.d.ts.map +1 -0
- package/lib/typescript/module/api/client.d.ts +35 -1
- package/lib/typescript/module/api/client.d.ts.map +1 -1
- package/lib/typescript/module/attachments/file-size.d.ts +15 -0
- package/lib/typescript/module/attachments/file-size.d.ts.map +1 -0
- package/lib/typescript/module/attachments/local-previews.d.ts +14 -0
- package/lib/typescript/module/attachments/local-previews.d.ts.map +1 -0
- package/lib/typescript/module/attachments/pickers.d.ts +35 -10
- package/lib/typescript/module/attachments/pickers.d.ts.map +1 -1
- package/lib/typescript/module/i18n.d.ts +13 -3
- package/lib/typescript/module/i18n.d.ts.map +1 -1
- package/lib/typescript/module/locales.d.ts.map +1 -1
- package/lib/typescript/module/state/widget-store.d.ts +67 -4
- package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/module/storage.d.ts +2 -0
- package/lib/typescript/module/storage.d.ts.map +1 -1
- package/lib/typescript/module/types.d.ts +58 -0
- package/lib/typescript/module/types.d.ts.map +1 -1
- package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/AttachmentChips.d.ts +13 -3
- package/lib/typescript/module/ui/components/AttachmentChips.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/ConversationRow.d.ts +15 -1
- package/lib/typescript/module/ui/components/ConversationRow.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/ImageViewer.d.ts +26 -6
- package/lib/typescript/module/ui/components/ImageViewer.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/RatingCard.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/ZoomableImage.d.ts +10 -1
- package/lib/typescript/module/ui/components/ZoomableImage.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/icons.d.ts +2 -1
- package/lib/typescript/module/ui/components/icons.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/use-retained-state.d.ts +11 -0
- package/lib/typescript/module/ui/hooks/use-retained-state.d.ts.map +1 -0
- package/lib/typescript/module/ui/screens/chat/ChatView.d.ts +4 -4
- package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +154 -2
- package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +30 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/conversations/ConversationsList.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/conversations/ConversationsList.types.d.ts +2 -0
- package/lib/typescript/module/ui/screens/conversations/ConversationsList.types.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/home/HomeView.d.ts +7 -5
- package/lib/typescript/module/ui/screens/home/HomeView.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/home/HomeView.styles.d.ts +30 -35
- package/lib/typescript/module/ui/screens/home/HomeView.styles.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/home/HomeView.types.d.ts +5 -6
- package/lib/typescript/module/ui/screens/home/HomeView.types.d.ts.map +1 -1
- package/lib/typescript/module/ui/styles/theme.d.ts +2 -0
- package/lib/typescript/module/ui/styles/theme.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/conversations.d.ts +10 -2
- package/lib/typescript/module/ui/utils/conversations.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/html.d.ts +5 -0
- package/lib/typescript/module/ui/utils/html.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/image-load-queue.d.ts +44 -0
- package/lib/typescript/module/ui/utils/image-load-queue.d.ts.map +1 -0
- package/lib/typescript/module/ui/utils/inline-attachments.d.ts +29 -0
- package/lib/typescript/module/ui/utils/inline-attachments.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/api/client.ts +148 -2
- package/src/attachments/file-size.ts +42 -0
- package/src/attachments/local-previews.ts +32 -0
- package/src/attachments/pickers.ts +90 -30
- package/src/i18n.ts +24 -3
- package/src/locales.ts +13 -3
- package/src/state/widget-store.ts +519 -70
- package/src/storage.ts +2 -0
- package/src/types.ts +58 -0
- package/src/ui/WidgetProvider.tsx +50 -12
- package/src/ui/components/AttachmentChips.tsx +100 -29
- package/src/ui/components/ConversationRow.tsx +90 -9
- package/src/ui/components/ImageViewer.tsx +250 -28
- package/src/ui/components/RatingCard.tsx +5 -2
- package/src/ui/components/ZoomableImage.tsx +58 -5
- package/src/ui/components/icons.tsx +10 -12
- package/src/ui/hooks/use-retained-state.ts +35 -0
- package/src/ui/screens/chat/ChatView.styles.ts +151 -3
- package/src/ui/screens/chat/ChatView.tsx +760 -213
- package/src/ui/screens/chat/ChatView.types.ts +30 -1
- package/src/ui/screens/conversations/ConversationsList.tsx +1 -0
- package/src/ui/screens/conversations/ConversationsList.types.ts +2 -0
- package/src/ui/screens/home/HomeView.styles.ts +36 -45
- package/src/ui/screens/home/HomeView.tsx +54 -70
- package/src/ui/screens/home/HomeView.types.ts +5 -6
- package/src/ui/styles/theme.ts +6 -0
- package/src/ui/utils/conversations.ts +16 -2
- package/src/ui/utils/html.ts +26 -0
- package/src/ui/utils/image-load-queue.ts +108 -0
- package/src/ui/utils/inline-attachments.ts +71 -0
|
@@ -11,8 +11,12 @@ var _deviceId = require("../device-id.js");
|
|
|
11
11
|
var _events = require("../events.js");
|
|
12
12
|
var _pushNotifications = require("../push/push-notifications.js");
|
|
13
13
|
var _storage = require("../storage.js");
|
|
14
|
+
var _localPreviews = require("../attachments/local-previews.js");
|
|
14
15
|
var _pickers = require("../attachments/pickers.js");
|
|
15
16
|
var _types = require("../types.js");
|
|
17
|
+
var _useRetainedState = require("../ui/hooks/use-retained-state.js");
|
|
18
|
+
var _conversations = require("../ui/utils/conversations.js");
|
|
19
|
+
var _html = require("../ui/utils/html.js");
|
|
16
20
|
var _safeUrl = require("../ui/utils/safe-url.js");
|
|
17
21
|
/**
|
|
18
22
|
* The widget's single source of truth.
|
|
@@ -25,7 +29,7 @@ var _safeUrl = require("../ui/utils/safe-url.js");
|
|
|
25
29
|
*/
|
|
26
30
|
|
|
27
31
|
/** Sent on every init; must match `package.json` (tests/version.test.js checks). */
|
|
28
|
-
const SDK_VERSION = '@repzo/widget-react-native@1.
|
|
32
|
+
const SDK_VERSION = '@repzo/widget-react-native@1.4.0';
|
|
29
33
|
|
|
30
34
|
/** The platform this session reports — mobile sessions get the Mobile SDKs overrides. */
|
|
31
35
|
function sessionPlatform() {
|
|
@@ -40,6 +44,12 @@ const TYPING_SETTLE_MS = 3_000;
|
|
|
40
44
|
const TYPING_PING_INTERVAL_MS = 4_000;
|
|
41
45
|
/** Guards against the SSE stream replaying messages after a reconnect. */
|
|
42
46
|
const SEEN_MESSAGE_LIMIT = 100;
|
|
47
|
+
/** Threads whose newest message is remembered across launches. */
|
|
48
|
+
const LAST_MESSAGE_LIMIT = 50;
|
|
49
|
+
/** How far the phone's clock may be from Workstation's for a sent message to still match the list. */
|
|
50
|
+
const SAME_MESSAGE_DRIFT_MS = 120_000;
|
|
51
|
+
/** How far the list's `lastMessageAt` may trail the message's own `createdAt` (7 ms seen live). */
|
|
52
|
+
const LIST_LAG_MS = 1_000;
|
|
43
53
|
/** Logout's server calls are best-effort — a dead network must not block sign-out. */
|
|
44
54
|
const LOGOUT_REQUEST_TIMEOUT_MS = 5_000;
|
|
45
55
|
/** A server that 401s every fresh stream must not spin init (10/min limit). */
|
|
@@ -55,6 +65,8 @@ const MARK_READ_DEBOUNCE_MS = 1_000;
|
|
|
55
65
|
const FOREGROUND_REFRESH_AFTER_MS = 5 * 60_000;
|
|
56
66
|
/** Live events held back while a draft's first send is creating its thread. */
|
|
57
67
|
const DRAFT_EVENT_BUFFER_LIMIT = 50;
|
|
68
|
+
/** Waits between re-reads for a sent file's stored copy while Workstation is still storing it. */
|
|
69
|
+
const SERVER_COPY_RETRY_MS = [2_000, 5_000, 15_000];
|
|
58
70
|
/** How long a post stays up after a reaction, thanking them, before it goes. */
|
|
59
71
|
const POST_THANKS_MS = exports.POST_THANKS_MS = 1600;
|
|
60
72
|
|
|
@@ -131,6 +143,10 @@ class WidgetStore {
|
|
|
131
143
|
|
|
132
144
|
/** `boot({ posts })` — Send posts over the app. */
|
|
133
145
|
postsEnabled = true;
|
|
146
|
+
/** `boot({ photoAlbums })` — a message's photos share one album in the thread. */
|
|
147
|
+
photoAlbumsEnabled = true;
|
|
148
|
+
/** `boot({ youPrefix })` — "You:" before the visitor's own newest message in the lists. */
|
|
149
|
+
youPrefixEnabled = true;
|
|
134
150
|
|
|
135
151
|
/** Posts whose view was reported this session (the server also dedupes per day). */
|
|
136
152
|
viewedPostIds = new Set();
|
|
@@ -185,8 +201,18 @@ class WidgetStore {
|
|
|
185
201
|
lastTypingPingAt = 0;
|
|
186
202
|
visitorTypingActive = false;
|
|
187
203
|
seenMessageIds = [];
|
|
204
|
+
|
|
205
|
+
/** Who sent each thread's newest message and what it carried; see `withLastMessage`. */
|
|
206
|
+
lastMessages = {};
|
|
207
|
+
lastMessagesLoaded = false;
|
|
188
208
|
/** Statuses (e.g. "read") that arrived for an id before a send response revealed it. */
|
|
189
209
|
earlyStatuses = new Map();
|
|
210
|
+
/** The agent UI action behind a failed bubble, so Retry runs it again too. */
|
|
211
|
+
failedActions = new Map();
|
|
212
|
+
/** The next re-read for a sent file's stored copy (see `syncServerCopies`). */
|
|
213
|
+
serverCopyTimer = null;
|
|
214
|
+
serverCopyRetries = 0;
|
|
215
|
+
serverCopySyncing = false;
|
|
190
216
|
|
|
191
217
|
/**
|
|
192
218
|
* Identity generation. Bumped whenever boot/login/logout is requested; every
|
|
@@ -258,6 +284,8 @@ class WidgetStore {
|
|
|
258
284
|
|
|
259
285
|
/** Composer identity: explicit opens reset it; a draft receiving its server ID does not. */
|
|
260
286
|
getThreadGeneration = () => this.threadGeneration;
|
|
287
|
+
showsPhotoAlbums = () => this.photoAlbumsEnabled;
|
|
288
|
+
showsYouPrefix = () => this.youPrefixEnabled;
|
|
261
289
|
setState(patch) {
|
|
262
290
|
const previous = this.state;
|
|
263
291
|
const resolved = typeof patch === 'function' ? patch(previous) : patch;
|
|
@@ -329,6 +357,8 @@ class WidgetStore {
|
|
|
329
357
|
if (options.inAppNotifications !== undefined) {
|
|
330
358
|
this.inAppNotificationsEnabled = options.inAppNotifications !== false;
|
|
331
359
|
}
|
|
360
|
+
if (options.photoAlbums !== undefined) this.photoAlbumsEnabled = options.photoAlbums !== false;
|
|
361
|
+
if (options.youPrefix !== undefined) this.youPrefixEnabled = options.youPrefix !== false;
|
|
332
362
|
if (options.posts !== undefined) {
|
|
333
363
|
const wasEnabled = this.postsEnabled;
|
|
334
364
|
this.postsEnabled = options.posts !== false;
|
|
@@ -483,13 +513,13 @@ class WidgetStore {
|
|
|
483
513
|
// queued op must never see one without the other.
|
|
484
514
|
await Promise.all([this.client.setToken(result.token), userKey ? _storage.SecureStorage.setItem(_storage.USER_KEY, userKey) : _storage.SecureStorage.removeItem(_storage.USER_KEY)]);
|
|
485
515
|
if (this.isStale(epoch)) return;
|
|
486
|
-
const conversations = result.conversations.map(conversation => this.hydrateConversation(conversation));
|
|
487
516
|
|
|
488
517
|
// Server counts win (they include replies pushed while the app was
|
|
489
518
|
// killed); the locally persisted map only covers older Workstation
|
|
490
519
|
// deployments that don't send unreadCount yet.
|
|
491
|
-
const storedUnread = await this.loadUnread();
|
|
520
|
+
const [, storedUnread] = await Promise.all([this.loadLastMessages(epoch), this.loadUnread()]);
|
|
492
521
|
if (this.isStale(epoch)) return;
|
|
522
|
+
const conversations = result.conversations.map(conversation => this.hydrateConversation(conversation));
|
|
493
523
|
const unreadByConversation = {};
|
|
494
524
|
for (const conversation of conversations) {
|
|
495
525
|
const count = typeof conversation.unreadCount === 'number' ? conversation.unreadCount : storedUnread[conversation.id] ?? 0;
|
|
@@ -780,16 +810,20 @@ class WidgetStore {
|
|
|
780
810
|
this.userKeyLoaded = true;
|
|
781
811
|
this.pendingAgentByConversation = {};
|
|
782
812
|
this.seenMessageIds = [];
|
|
813
|
+
this.lastMessages = {};
|
|
814
|
+
this.lastMessagesLoaded = false;
|
|
783
815
|
this.earlyStatuses.clear();
|
|
784
816
|
this.visitorTypingActive = false;
|
|
785
817
|
this.lastTypingPingAt = 0;
|
|
786
818
|
this.launchCardOffered = false;
|
|
787
819
|
this.resetPost();
|
|
820
|
+
(0, _localPreviews.clearLocalPreviews)();
|
|
821
|
+
(0, _useRetainedState.clearRetainedState)();
|
|
788
822
|
this.setState({
|
|
789
823
|
...USER_SCOPED_STATE
|
|
790
824
|
});
|
|
791
825
|
const token = await this.client.getToken();
|
|
792
|
-
await Promise.all([this.client.clearToken(), _storage.SecureStorage.removeItem(_storage.USER_KEY), _storage.Storage.removeItem(_storage.UNREAD_KEY),
|
|
826
|
+
await Promise.all([this.client.clearToken(), _storage.SecureStorage.removeItem(_storage.USER_KEY), _storage.Storage.removeItem(_storage.UNREAD_KEY), _storage.Storage.removeItem(_storage.LAST_MESSAGE_KEY),
|
|
793
827
|
// A new anonymous visitor on this device gets a new tracking identity.
|
|
794
828
|
(0, _deviceId.rotateDeviceId)()]);
|
|
795
829
|
|
|
@@ -825,7 +859,9 @@ class WidgetStore {
|
|
|
825
859
|
this.threadGeneration += 1;
|
|
826
860
|
this.conversationCreation = null;
|
|
827
861
|
this.draftEvents = [];
|
|
862
|
+
this.failedActions.clear();
|
|
828
863
|
this.cancelMarkRead();
|
|
864
|
+
this.cancelServerCopySync();
|
|
829
865
|
}
|
|
830
866
|
requireBootConfig() {
|
|
831
867
|
if (!this.bootConfig) {
|
|
@@ -1141,7 +1177,10 @@ class WidgetStore {
|
|
|
1141
1177
|
attachments: this.hydrateAttachments(data?.attachments, messageId),
|
|
1142
1178
|
metadata: this.hydrateMetadata(data?.metadata || null),
|
|
1143
1179
|
status: 'delivered',
|
|
1144
|
-
createdAt: data?.createdAt || new Date().toISOString()
|
|
1180
|
+
createdAt: data?.createdAt || new Date().toISOString(),
|
|
1181
|
+
...(typeof data?.clientMessageId === 'string' ? {
|
|
1182
|
+
clientMessageId: data.clientMessageId
|
|
1183
|
+
} : {})
|
|
1145
1184
|
};
|
|
1146
1185
|
const clearsTyping = message.direction === 'outbound' && message.senderType !== 'visitor';
|
|
1147
1186
|
if (clearsTyping) this.clearTypingTimer();
|
|
@@ -1164,18 +1203,20 @@ class WidgetStore {
|
|
|
1164
1203
|
|
|
1165
1204
|
// Set inside the updater, read after: `setState` runs synchronously.
|
|
1166
1205
|
let countedAsUnread = false;
|
|
1206
|
+
const rowFields = rowFieldsOf(message);
|
|
1167
1207
|
this.setState(current => {
|
|
1168
1208
|
const conversations = conversationId ? current.conversations.map(conversation => conversation.id === conversationId ? {
|
|
1169
1209
|
...conversation,
|
|
1170
|
-
|
|
1210
|
+
...rowFields,
|
|
1171
1211
|
lastMessageAt: message.createdAt,
|
|
1172
1212
|
messageCount: conversation.messageCount + 1
|
|
1173
1213
|
} : conversation) : current.conversations;
|
|
1174
1214
|
if (onScreen) {
|
|
1175
|
-
const
|
|
1215
|
+
const shown = adoptOwnServerRows(current.messages, [message]);
|
|
1216
|
+
const alreadyPresent = shown.some(existing => existing.id === message.id);
|
|
1176
1217
|
return {
|
|
1177
1218
|
conversations,
|
|
1178
|
-
messages: alreadyPresent ? adoptServerAttachments(
|
|
1219
|
+
messages: alreadyPresent ? adoptServerAttachments(shown, [message]) : [...shown, message],
|
|
1179
1220
|
agentTyping: clearsTyping ? false : current.agentTyping
|
|
1180
1221
|
};
|
|
1181
1222
|
}
|
|
@@ -1204,11 +1245,12 @@ class WidgetStore {
|
|
|
1204
1245
|
messageId,
|
|
1205
1246
|
senderName: message.senderName || conversation?.agentName || null,
|
|
1206
1247
|
senderAvatar: conversation?.agentAvatar ?? null,
|
|
1207
|
-
preview: (message
|
|
1248
|
+
preview: plainTextOf(message),
|
|
1208
1249
|
hasAttachments: (message.attachments?.length ?? 0) > 0
|
|
1209
1250
|
}
|
|
1210
1251
|
};
|
|
1211
1252
|
});
|
|
1253
|
+
if (conversationId && rowFields) this.rememberLastMessage(conversationId, message.createdAt, rowFields, 'stream');
|
|
1212
1254
|
|
|
1213
1255
|
// The visitor is reading it live — move the server cursor too, or the
|
|
1214
1256
|
// next list/init would badge a reply they already saw.
|
|
@@ -1306,10 +1348,112 @@ class WidgetStore {
|
|
|
1306
1348
|
};
|
|
1307
1349
|
}
|
|
1308
1350
|
hydrateConversation(conversation) {
|
|
1309
|
-
return this.applyConversationAgent({
|
|
1351
|
+
return this.applyConversationAgent(this.withLastMessage({
|
|
1310
1352
|
...conversation,
|
|
1311
1353
|
agentAvatar: this.client.resolveUrl(conversation.agentAvatar)
|
|
1354
|
+
}));
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/**
|
|
1358
|
+
* Workstation's list doesn't say who sent the newest message, and calls one
|
|
1359
|
+
* without words "2 attachments". While a row still describes the message
|
|
1360
|
+
* this device saw last, both are filled in from it. `snippet` keeps the
|
|
1361
|
+
* list's words: apps read it, and the lists draw "Photo" or "File" from
|
|
1362
|
+
* `lastMessageAttachment` instead.
|
|
1363
|
+
*/
|
|
1364
|
+
withLastMessage(conversation) {
|
|
1365
|
+
const known = this.lastMessages[conversation.id];
|
|
1366
|
+
if (!known || !describesSameMessage(conversation, known)) return conversation;
|
|
1367
|
+
// A list that says who sent it, or what it carried, is used as is.
|
|
1368
|
+
return {
|
|
1369
|
+
...conversation,
|
|
1370
|
+
lastMessageFromVisitor: conversation.lastMessageFromVisitor ?? known.fromVisitor,
|
|
1371
|
+
lastMessageAttachment: conversation.lastMessageAttachment !== undefined ? conversation.lastMessageAttachment : known.attachment
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* A send or a streamed message is the newest by arrival, whatever the two
|
|
1377
|
+
* clocks say; a history page only replaces something older. A sent message
|
|
1378
|
+
* is timed by the phone, which may run ahead of Workstation, so instead a
|
|
1379
|
+
* page that has it replaces it: whatever is newest there came after it, and
|
|
1380
|
+
* Workstation's own time then stands in for the phone's.
|
|
1381
|
+
*/
|
|
1382
|
+
rememberLastMessage(conversationId, at, fields, source, {
|
|
1383
|
+
messageId,
|
|
1384
|
+
pageIds
|
|
1385
|
+
} = {}) {
|
|
1386
|
+
const known = this.lastMessages[conversationId];
|
|
1387
|
+
if (source === 'history' && known) {
|
|
1388
|
+
const outdated = known.phoneClock && known.messageId ? !pageIds?.has(known.messageId) : Date.parse(known.at) > Date.parse(at);
|
|
1389
|
+
if (outdated) return;
|
|
1390
|
+
}
|
|
1391
|
+
const hint = {
|
|
1392
|
+
at,
|
|
1393
|
+
fromVisitor: !!fields.lastMessageFromVisitor,
|
|
1394
|
+
attachment: fields.lastMessageAttachment ?? null,
|
|
1395
|
+
...(source === 'sent' ? {
|
|
1396
|
+
phoneClock: true,
|
|
1397
|
+
...(messageId ? {
|
|
1398
|
+
messageId
|
|
1399
|
+
} : {})
|
|
1400
|
+
} : {})
|
|
1401
|
+
};
|
|
1402
|
+
this.lastMessages = Object.fromEntries(Object.entries({
|
|
1403
|
+
...this.lastMessages,
|
|
1404
|
+
[conversationId]: hint
|
|
1405
|
+
}).sort(([, a], [, b]) => Date.parse(b.at) - Date.parse(a.at)).slice(0, LAST_MESSAGE_LIMIT));
|
|
1406
|
+
// Until the stored hints are read, saving would overwrite them with these alone.
|
|
1407
|
+
if (this.lastMessagesLoaded) void _storage.Storage.setItem(_storage.LAST_MESSAGE_KEY, JSON.stringify(this.lastMessages));
|
|
1408
|
+
}
|
|
1409
|
+
async loadLastMessages(epoch) {
|
|
1410
|
+
if (this.lastMessagesLoaded) return;
|
|
1411
|
+
let stored = null;
|
|
1412
|
+
try {
|
|
1413
|
+
stored = JSON.parse((await _storage.Storage.getItem(_storage.LAST_MESSAGE_KEY)) ?? 'null');
|
|
1414
|
+
} catch {
|
|
1415
|
+
stored = null;
|
|
1416
|
+
}
|
|
1417
|
+
// Left unloaded: the init that replaces this one reads them again.
|
|
1418
|
+
if (this.isStale(epoch) || this.lastMessagesLoaded) return;
|
|
1419
|
+
this.lastMessagesLoaded = true;
|
|
1420
|
+
const learned = Object.keys(this.lastMessages).length > 0;
|
|
1421
|
+
if (stored && typeof stored === 'object') {
|
|
1422
|
+
for (const [conversationId, hint] of Object.entries(stored)) {
|
|
1423
|
+
if (typeof hint?.at !== 'string' || typeof hint.fromVisitor !== 'boolean') continue;
|
|
1424
|
+
const known = this.lastMessages[conversationId];
|
|
1425
|
+
if (known && Date.parse(known.at) >= Date.parse(hint.at)) continue;
|
|
1426
|
+
this.lastMessages[conversationId] = {
|
|
1427
|
+
at: hint.at,
|
|
1428
|
+
fromVisitor: hint.fromVisitor,
|
|
1429
|
+
attachment: hint.attachment === 'photo' || hint.attachment === 'file' ? hint.attachment : null,
|
|
1430
|
+
...(hint.phoneClock === true ? {
|
|
1431
|
+
phoneClock: true
|
|
1432
|
+
} : {}),
|
|
1433
|
+
...(typeof hint.messageId === 'string' ? {
|
|
1434
|
+
messageId: hint.messageId
|
|
1435
|
+
} : {})
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
// Hints learned while the stored ones were being read are saved with them.
|
|
1440
|
+
if (learned) void _storage.Storage.setItem(_storage.LAST_MESSAGE_KEY, JSON.stringify(this.lastMessages));
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
/** A thread's newest page shows who sent its newest message, so its row can say too. */
|
|
1444
|
+
learnLastMessage(conversationId, page) {
|
|
1445
|
+
let newest = null;
|
|
1446
|
+
for (const message of page) {
|
|
1447
|
+
if (!newest || Date.parse(message.createdAt) > Date.parse(newest.createdAt)) newest = message;
|
|
1448
|
+
}
|
|
1449
|
+
const fields = newest && rowFieldsOf(newest);
|
|
1450
|
+
if (!newest || !fields) return;
|
|
1451
|
+
this.rememberLastMessage(conversationId, newest.createdAt, fields, 'history', {
|
|
1452
|
+
pageIds: new Set(page.map(message => message.id))
|
|
1312
1453
|
});
|
|
1454
|
+
this.setState(current => ({
|
|
1455
|
+
conversations: current.conversations.map(row => row.id === conversationId ? this.withLastMessage(row) : row)
|
|
1456
|
+
}));
|
|
1313
1457
|
}
|
|
1314
1458
|
|
|
1315
1459
|
/**
|
|
@@ -1318,16 +1462,22 @@ class WidgetStore {
|
|
|
1318
1462
|
*/
|
|
1319
1463
|
hydrateAttachments(attachments, messageId) {
|
|
1320
1464
|
if (!Array.isArray(attachments) || attachments.length === 0) return null;
|
|
1321
|
-
const hydrated = attachments.filter(item => !!item && typeof item === 'object').map((attachment, index) =>
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1465
|
+
const hydrated = attachments.filter(item => !!item && typeof item === 'object').map((attachment, index) => {
|
|
1466
|
+
const name = typeof attachment.name === 'string' && attachment.name ? attachment.name : 'Attachment';
|
|
1467
|
+
const declared = typeof attachment.contentType === 'string' ? attachment.contentType : '';
|
|
1468
|
+
return {
|
|
1469
|
+
...attachment,
|
|
1470
|
+
id: typeof attachment.id === 'string' || typeof attachment.id === 'number' ? String(attachment.id) : `${messageId ?? 'attachment'}-${index}`,
|
|
1471
|
+
name,
|
|
1472
|
+
// Without a type (or with a generic one) the name says what it is,
|
|
1473
|
+
// so a photo still shows as a photo rather than a file row.
|
|
1474
|
+
contentType: declared && !/^application\/octet-stream$/i.test(declared) ? declared : (0, _pickers.guessContentType)(name) ?? declared,
|
|
1475
|
+
size: typeof attachment.size === 'number' ? attachment.size : 0,
|
|
1476
|
+
// Empty (never the raw value) when the URL isn't http(s) — the preview
|
|
1477
|
+
// then renders the name only, with nothing to open.
|
|
1478
|
+
url: this.client.resolveUrl(typeof attachment.url === 'string' ? attachment.url : null) ?? ''
|
|
1479
|
+
};
|
|
1480
|
+
});
|
|
1331
1481
|
return hydrated.length > 0 ? hydrated : null;
|
|
1332
1482
|
}
|
|
1333
1483
|
clearTypingTimer() {
|
|
@@ -1583,19 +1733,21 @@ class WidgetStore {
|
|
|
1583
1733
|
const result = await this.client.listMessages(conversationId);
|
|
1584
1734
|
// Opening A then B quickly: A's page must never land under B.
|
|
1585
1735
|
if (this.isStale(epoch) || this.threadGeneration !== generation) return;
|
|
1736
|
+
const page = this.hydrateMessages(result.data);
|
|
1586
1737
|
this.setState(current => {
|
|
1587
1738
|
// Live replies and optimistic sends may have arrived since this page
|
|
1588
1739
|
// was requested. Keep them, preferring the live copy for shared ids.
|
|
1589
|
-
const
|
|
1590
|
-
const
|
|
1740
|
+
const live = adoptOwnServerRows(current.messages, page);
|
|
1741
|
+
const liveIds = new Set(live.map(message => message.id));
|
|
1591
1742
|
const history = page.filter(message => !liveIds.has(message.id));
|
|
1592
1743
|
return {
|
|
1593
|
-
messages: sortByCreatedAt([...history, ...adoptServerAttachments(
|
|
1744
|
+
messages: sortByCreatedAt([...history, ...adoptServerAttachments(live, page)]),
|
|
1594
1745
|
messagesLoading: false,
|
|
1595
1746
|
hasMoreMessages: result.meta.hasMore,
|
|
1596
1747
|
messageCursor: result.meta.nextCursor
|
|
1597
1748
|
};
|
|
1598
1749
|
});
|
|
1750
|
+
this.learnLastMessage(conversationId, page);
|
|
1599
1751
|
} catch {
|
|
1600
1752
|
if (!this.isStale(epoch) && this.threadGeneration === generation) {
|
|
1601
1753
|
this.setState({
|
|
@@ -1657,15 +1809,17 @@ class WidgetStore {
|
|
|
1657
1809
|
if (this.isStale(epoch) || this.threadGeneration !== generation) return;
|
|
1658
1810
|
if (this.state.activeConversationId !== conversationId) return;
|
|
1659
1811
|
const fresh = this.hydrateMessages(result.data);
|
|
1812
|
+
this.learnLastMessage(conversationId, fresh);
|
|
1660
1813
|
this.setState(current => {
|
|
1661
|
-
const
|
|
1814
|
+
const own = adoptOwnServerRows(current.messages, fresh);
|
|
1815
|
+
const known = new Set(own.map(message => message.id));
|
|
1662
1816
|
const missing = fresh.filter(message => !known.has(message.id));
|
|
1663
|
-
const upgraded = adoptServerStatuses(adoptServerDeletions(adoptServerAttachments(
|
|
1817
|
+
const upgraded = adoptServerStatuses(adoptServerDeletions(adoptServerAttachments(own, fresh), fresh), fresh);
|
|
1664
1818
|
if (missing.length === 0) return upgraded === current.messages ? {} : {
|
|
1665
1819
|
messages: upgraded
|
|
1666
1820
|
};
|
|
1667
|
-
const local =
|
|
1668
|
-
const hasServerMessages = local.length <
|
|
1821
|
+
const local = own.filter(_conversations.isLocalMessage);
|
|
1822
|
+
const hasServerMessages = local.length < own.length;
|
|
1669
1823
|
// No overlap means the gap is longer than a page: restart from the
|
|
1670
1824
|
// newest page (keeping unsent bubbles) rather than leave a hole.
|
|
1671
1825
|
if (missing.length === fresh.length || !hasServerMessages) {
|
|
@@ -1690,6 +1844,40 @@ class WidgetStore {
|
|
|
1690
1844
|
}
|
|
1691
1845
|
}
|
|
1692
1846
|
|
|
1847
|
+
/**
|
|
1848
|
+
* Until its stored copy replaces it, a sent bubble points at the visitor's
|
|
1849
|
+
* local files: a photo still opens in the viewer, but a PDF has nothing to
|
|
1850
|
+
* open. When the stream's echo hasn't brought that copy, re-read the
|
|
1851
|
+
* thread, and try again a few times while Workstation is still storing it.
|
|
1852
|
+
*/
|
|
1853
|
+
async syncServerCopies() {
|
|
1854
|
+
this.cancelServerCopySync();
|
|
1855
|
+
if (this.serverCopySyncing || !this.state.messages.some(isAwaitingServerCopy)) return;
|
|
1856
|
+
// A send still in flight would come back as a stored row beside its own
|
|
1857
|
+
// pending bubble. It syncs again when it settles.
|
|
1858
|
+
if (this.state.messages.some(message => (0, _conversations.isLocalMessage)(message) && message.status === 'pending')) return;
|
|
1859
|
+
// Off screen: the thread reloads in full when it's opened again.
|
|
1860
|
+
const conversationId = this.state.activeConversationId;
|
|
1861
|
+
if (!conversationId || !this.isThreadOnScreen(conversationId)) return;
|
|
1862
|
+
const generation = this.threadGeneration;
|
|
1863
|
+
this.serverCopySyncing = true;
|
|
1864
|
+
try {
|
|
1865
|
+
// A no-op while a page load is in flight; the retry below covers it.
|
|
1866
|
+
await this.refreshOpenThread();
|
|
1867
|
+
} finally {
|
|
1868
|
+
this.serverCopySyncing = false;
|
|
1869
|
+
}
|
|
1870
|
+
if (this.threadGeneration !== generation || !this.state.messages.some(isAwaitingServerCopy)) return;
|
|
1871
|
+
const delay = SERVER_COPY_RETRY_MS[this.serverCopyRetries];
|
|
1872
|
+
if (delay === undefined) return;
|
|
1873
|
+
this.serverCopyRetries += 1;
|
|
1874
|
+
this.serverCopyTimer = setTimeout(() => void this.syncServerCopies(), delay);
|
|
1875
|
+
}
|
|
1876
|
+
cancelServerCopySync() {
|
|
1877
|
+
if (this.serverCopyTimer) clearTimeout(this.serverCopyTimer);
|
|
1878
|
+
this.serverCopyTimer = null;
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1693
1881
|
/** A history page (newest-first) as the oldest-first list the UI renders. */
|
|
1694
1882
|
hydrateMessages(page) {
|
|
1695
1883
|
return page.map(message => ({
|
|
@@ -1737,9 +1925,11 @@ class WidgetStore {
|
|
|
1737
1925
|
|
|
1738
1926
|
/**
|
|
1739
1927
|
* Rejects when the message wasn't sent, so the composer can keep
|
|
1740
|
-
* attachments for a retry. A text-only bubble is marked failed
|
|
1741
|
-
* attachments is removed instead — the
|
|
1742
|
-
* keeping both would leave a duplicate
|
|
1928
|
+
* attachments for a retry. A text-only bubble is marked failed and can be
|
|
1929
|
+
* re-sent with `retryMessage`; one with attachments is removed instead — the
|
|
1930
|
+
* composer's chips are the retry, and keeping both would leave a duplicate
|
|
1931
|
+
* after a successful resend. Likewise an agent UI action replaces the
|
|
1932
|
+
* failed bubble of the same action, since its card stays tappable.
|
|
1743
1933
|
*/
|
|
1744
1934
|
async sendMessage(body, attachments = [], agentUiAction) {
|
|
1745
1935
|
const trimmed = body.trim();
|
|
@@ -1747,7 +1937,6 @@ class WidgetStore {
|
|
|
1747
1937
|
const {
|
|
1748
1938
|
visitorProfile
|
|
1749
1939
|
} = this.state;
|
|
1750
|
-
const generation = this.threadGeneration;
|
|
1751
1940
|
const tempId = `temp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1752
1941
|
const optimistic = {
|
|
1753
1942
|
id: tempId,
|
|
@@ -1771,20 +1960,67 @@ class WidgetStore {
|
|
|
1771
1960
|
status: 'pending',
|
|
1772
1961
|
createdAt: new Date().toISOString()
|
|
1773
1962
|
};
|
|
1963
|
+
|
|
1964
|
+
// Tapping the card again is the retry: offering the old bubble's retry
|
|
1965
|
+
// too would run the action twice (two items in the cart, two bookings).
|
|
1966
|
+
const superseded = agentUiAction ? [...this.failedActions].filter(([, failed]) => failed.id === agentUiAction.id && failed.type === agentUiAction.type).map(([messageId]) => messageId) : [];
|
|
1967
|
+
superseded.forEach(messageId => this.failedActions.delete(messageId));
|
|
1774
1968
|
this.setState(current => ({
|
|
1775
|
-
messages: [...current.messages, optimistic],
|
|
1969
|
+
messages: [...current.messages.filter(message => !superseded.includes(message.id)), optimistic],
|
|
1776
1970
|
prefillText: ''
|
|
1777
1971
|
}));
|
|
1778
1972
|
this.track('widget_message_sent', {
|
|
1779
1973
|
hasText: !!trimmed,
|
|
1780
1974
|
attachmentCount: attachments.length
|
|
1781
1975
|
});
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1976
|
+
return this.deliverMessage(tempId, trimmed, attachments, agentUiAction);
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
/**
|
|
1980
|
+
* Puts a picked file in storage while the visitor is still writing, so
|
|
1981
|
+
* Send goes out at once. Resolves the token `sendMessage` names it by, and
|
|
1982
|
+
* when that token expires.
|
|
1983
|
+
*/
|
|
1984
|
+
uploadAttachment(file, signal) {
|
|
1985
|
+
return this.client.uploadAttachment(file, signal);
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
/**
|
|
1989
|
+
* Re-sends a text bubble that failed. It moves to the end of the thread,
|
|
1990
|
+
* since it now goes out after anything sent meanwhile. Rejects like
|
|
1991
|
+
* `sendMessage` when it fails again; the bubble is then failed once more.
|
|
1992
|
+
*/
|
|
1993
|
+
async retryMessage(messageId) {
|
|
1994
|
+
const failed = this.state.messages.find(message => message.id === messageId && (0, _conversations.canRetryMessage)(message));
|
|
1995
|
+
if (!failed) return;
|
|
1996
|
+
const agentUiAction = this.failedActions.get(messageId);
|
|
1997
|
+
this.failedActions.delete(messageId);
|
|
1998
|
+
const pending = {
|
|
1999
|
+
...failed,
|
|
2000
|
+
status: 'pending',
|
|
2001
|
+
createdAt: new Date().toISOString()
|
|
2002
|
+
};
|
|
2003
|
+
this.setState(current => ({
|
|
2004
|
+
messages: [...current.messages.filter(message => message.id !== messageId), pending]
|
|
1787
2005
|
}));
|
|
2006
|
+
return this.deliverMessage(messageId, failed.bodyPlain ?? '', [], agentUiAction);
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
/** Sends the optimistic bubble `tempId`, already on screen as pending. */
|
|
2010
|
+
async deliverMessage(tempId, trimmed, attachments, agentUiAction) {
|
|
2011
|
+
const {
|
|
2012
|
+
visitorProfile
|
|
2013
|
+
} = this.state;
|
|
2014
|
+
const generation = this.threadGeneration;
|
|
2015
|
+
const markFailed = () => {
|
|
2016
|
+
if (agentUiAction && attachments.length === 0) this.failedActions.set(tempId, agentUiAction);
|
|
2017
|
+
this.setState(current => ({
|
|
2018
|
+
messages: attachments.length > 0 ? current.messages.filter(message => message.id !== tempId) : current.messages.map(message => message.id === tempId ? {
|
|
2019
|
+
...message,
|
|
2020
|
+
status: 'failed'
|
|
2021
|
+
} : message)
|
|
2022
|
+
}));
|
|
2023
|
+
};
|
|
1788
2024
|
const epoch = this.epoch;
|
|
1789
2025
|
|
|
1790
2026
|
// A draft's first send creates the conversation. Anything sent from the
|
|
@@ -1810,27 +2046,56 @@ class WidgetStore {
|
|
|
1810
2046
|
}
|
|
1811
2047
|
let createdId = null;
|
|
1812
2048
|
try {
|
|
2049
|
+
// Local files go up first, unless the composer already put them up; the
|
|
2050
|
+
// message names each by its upload token.
|
|
2051
|
+
const sentAttachments = await Promise.all(attachments.map(async ({
|
|
2052
|
+
name,
|
|
2053
|
+
url,
|
|
2054
|
+
fileUri,
|
|
2055
|
+
uploadToken,
|
|
2056
|
+
contentType,
|
|
2057
|
+
size
|
|
2058
|
+
}) => uploadToken ? {
|
|
2059
|
+
name,
|
|
2060
|
+
contentType,
|
|
2061
|
+
size,
|
|
2062
|
+
uploadToken
|
|
2063
|
+
} : fileUri ? {
|
|
2064
|
+
name,
|
|
2065
|
+
contentType,
|
|
2066
|
+
size,
|
|
2067
|
+
uploadToken: (await this.client.uploadAttachment({
|
|
2068
|
+
uri: fileUri,
|
|
2069
|
+
name,
|
|
2070
|
+
contentType,
|
|
2071
|
+
size
|
|
2072
|
+
})).uploadToken
|
|
2073
|
+
} : {
|
|
2074
|
+
name,
|
|
2075
|
+
url,
|
|
2076
|
+
contentType,
|
|
2077
|
+
size
|
|
2078
|
+
}));
|
|
2079
|
+
if (this.isStale(epoch)) return;
|
|
1813
2080
|
const result = await this.client.sendMessage({
|
|
1814
2081
|
conversationId: conversationId || undefined,
|
|
1815
2082
|
forceNewConversation: !conversationId,
|
|
1816
2083
|
body: trimmed,
|
|
1817
|
-
attachments:
|
|
1818
|
-
name,
|
|
1819
|
-
url,
|
|
1820
|
-
contentType,
|
|
1821
|
-
size
|
|
1822
|
-
}) => ({
|
|
1823
|
-
name,
|
|
1824
|
-
url,
|
|
1825
|
-
contentType,
|
|
1826
|
-
size
|
|
1827
|
-
})) : undefined,
|
|
2084
|
+
attachments: sentAttachments.length > 0 ? sentAttachments : undefined,
|
|
1828
2085
|
visitorName: visitorProfile.name,
|
|
1829
2086
|
visitorEmail: visitorProfile.email,
|
|
1830
|
-
agentUiAction
|
|
2087
|
+
agentUiAction,
|
|
2088
|
+
// The bubble's id, the same on every retry: a newer Workstation stores
|
|
2089
|
+
// the message once even when an earlier attempt's response was lost.
|
|
2090
|
+
clientMessageId: tempId
|
|
1831
2091
|
});
|
|
1832
2092
|
if (this.isStale(epoch)) return;
|
|
1833
2093
|
if (ownCreation) createdId = result.conversationId;
|
|
2094
|
+
|
|
2095
|
+
// Workstation doesn't stream the visitor's own messages back, so the
|
|
2096
|
+
// list row learns about this one here.
|
|
2097
|
+
const sentAt = new Date().toISOString();
|
|
2098
|
+
const sentRow = rowFieldsFor(trimmed, attachments.map(attachment => attachment.contentType), true);
|
|
1834
2099
|
this.setState(current => {
|
|
1835
2100
|
// The stream or a catch-up fetch may have delivered this message
|
|
1836
2101
|
// already — keep one copy, never two bubbles with the same id.
|
|
@@ -1840,18 +2105,27 @@ class WidgetStore {
|
|
|
1840
2105
|
id: result.messageId,
|
|
1841
2106
|
status: this.earlyStatuses.get(result.messageId) ?? 'sent'
|
|
1842
2107
|
} : message);
|
|
1843
|
-
if (!createdId)
|
|
1844
|
-
|
|
1845
|
-
|
|
2108
|
+
if (!createdId) {
|
|
2109
|
+
return {
|
|
2110
|
+
messages,
|
|
2111
|
+
conversations: current.conversations.map(row => row.id === result.conversationId ? {
|
|
2112
|
+
...row,
|
|
2113
|
+
...sentRow,
|
|
2114
|
+
lastMessageAt: sentAt,
|
|
2115
|
+
messageCount: row.messageCount + (delivered ? 0 : 1)
|
|
2116
|
+
} : row)
|
|
2117
|
+
};
|
|
2118
|
+
}
|
|
1846
2119
|
const known = current.conversations.some(item => item.id === result.conversationId);
|
|
1847
2120
|
const conversation = {
|
|
1848
2121
|
id: result.conversationId,
|
|
1849
2122
|
subject: null,
|
|
1850
2123
|
status: 'open',
|
|
1851
|
-
snippet:
|
|
2124
|
+
snippet: null,
|
|
2125
|
+
...sentRow,
|
|
1852
2126
|
messageCount: 1,
|
|
1853
|
-
lastMessageAt:
|
|
1854
|
-
createdAt:
|
|
2127
|
+
lastMessageAt: sentAt,
|
|
2128
|
+
createdAt: sentAt,
|
|
1855
2129
|
agentName: null,
|
|
1856
2130
|
agentAvatar: null
|
|
1857
2131
|
};
|
|
@@ -1864,8 +2138,18 @@ class WidgetStore {
|
|
|
1864
2138
|
} : {})
|
|
1865
2139
|
};
|
|
1866
2140
|
});
|
|
2141
|
+
this.rememberLastMessage(result.conversationId, sentAt, sentRow, 'sent', {
|
|
2142
|
+
messageId: result.messageId
|
|
2143
|
+
});
|
|
2144
|
+
|
|
2145
|
+
// New files to fetch the stored copy of: a fresh round of re-reads.
|
|
2146
|
+
const sent = this.state.messages.find(message => message.id === result.messageId);
|
|
2147
|
+
if (sent && hasLocalAttachments(sent)) this.serverCopyRetries = 0;
|
|
1867
2148
|
} catch (error) {
|
|
1868
2149
|
if (this.isStale(epoch)) throw error;
|
|
2150
|
+
// The response was lost, but its stored row has already taken the
|
|
2151
|
+
// bubble's place (newer Workstation deployments): it was sent.
|
|
2152
|
+
if (this.state.messages.some(message => message.clientMessageId === tempId)) return;
|
|
1869
2153
|
markFailed();
|
|
1870
2154
|
throw error;
|
|
1871
2155
|
} finally {
|
|
@@ -1876,6 +2160,8 @@ class WidgetStore {
|
|
|
1876
2160
|
// Anything held back for the draft now has a home (or goes to unread).
|
|
1877
2161
|
this.replayDraftEvents();
|
|
1878
2162
|
}
|
|
2163
|
+
// Waits for every send to settle, so this runs when the last one does.
|
|
2164
|
+
if (!this.isStale(epoch)) void this.syncServerCopies();
|
|
1879
2165
|
}
|
|
1880
2166
|
}
|
|
1881
2167
|
async identifyVisitor(data) {
|
|
@@ -2350,11 +2636,6 @@ function createConversationCreation(generation) {
|
|
|
2350
2636
|
};
|
|
2351
2637
|
}
|
|
2352
2638
|
|
|
2353
|
-
/** Optimistic visitor bubbles the server hasn't acknowledged (pending/failed). */
|
|
2354
|
-
function isLocalMessage(message) {
|
|
2355
|
-
return message.id.startsWith('temp-');
|
|
2356
|
-
}
|
|
2357
|
-
|
|
2358
2639
|
/** A "Seen" missed while the stream was down shows up on the next re-read. */
|
|
2359
2640
|
function adoptServerStatuses(messages, server) {
|
|
2360
2641
|
const seen = new Set(server.filter(message => message.status === 'read').map(message => message.id));
|
|
@@ -2383,6 +2664,65 @@ function asDeletedPlaceholder(message, deletedAt) {
|
|
|
2383
2664
|
};
|
|
2384
2665
|
}
|
|
2385
2666
|
|
|
2667
|
+
/** A message's words on one line; replies written in Workstation may only carry HTML. */
|
|
2668
|
+
function plainTextOf(message) {
|
|
2669
|
+
return (message.bodyPlain || (0, _html.stripHtml)(message.body ?? '')).replace(/\s+/g, ' ').trim();
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
/**
|
|
2673
|
+
* The newest message of a thread as this device saw it. `at` is its
|
|
2674
|
+
* `createdAt`, which Workstation's list repeats as `lastMessageAt`; for a sent
|
|
2675
|
+
* message it's the phone's clock instead.
|
|
2676
|
+
*/
|
|
2677
|
+
|
|
2678
|
+
/** `snippet` is absent when there's nothing to quote: the row keeps its words. */
|
|
2679
|
+
|
|
2680
|
+
/** The list row once `message` is the newest. Null keeps the row as it is: system rows and notes. */
|
|
2681
|
+
function rowFieldsOf(message) {
|
|
2682
|
+
if (message.messageType && message.messageType !== 'message') return null;
|
|
2683
|
+
return rowFieldsFor(plainTextOf(message), (message.attachments ?? []).map(attachment => attachment.contentType), message.senderType === 'visitor' || message.direction === 'inbound', /<img\b/i.test(message.body ?? ''));
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2686
|
+
/**
|
|
2687
|
+
* Its words, or with none, whether it carried only photos or other files too.
|
|
2688
|
+
* A message with neither (a card) still says who sent it, so a teammate's
|
|
2689
|
+
* card never leaves "You:" on the visitor's earlier message.
|
|
2690
|
+
*/
|
|
2691
|
+
function rowFieldsFor(text, contentTypes, fromVisitor, inlinePhoto = false) {
|
|
2692
|
+
if (text) return {
|
|
2693
|
+
snippet: text,
|
|
2694
|
+
lastMessageAttachment: null,
|
|
2695
|
+
lastMessageFromVisitor: fromVisitor
|
|
2696
|
+
};
|
|
2697
|
+
const count = contentTypes.length;
|
|
2698
|
+
if (count === 0 && !inlinePhoto) return {
|
|
2699
|
+
lastMessageAttachment: null,
|
|
2700
|
+
lastMessageFromVisitor: fromVisitor
|
|
2701
|
+
};
|
|
2702
|
+
return {
|
|
2703
|
+
// Workstation's list words it this way too, so apps reading `snippet` see the same after a reload.
|
|
2704
|
+
...(count > 0 ? {
|
|
2705
|
+
snippet: `${count} attachment${count === 1 ? '' : 's'}`
|
|
2706
|
+
} : {}),
|
|
2707
|
+
lastMessageAttachment: contentTypes.every(type => (0, _pickers.isImageAttachment)(type)) ? 'photo' : 'file',
|
|
2708
|
+
lastMessageFromVisitor: fromVisitor
|
|
2709
|
+
};
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
/**
|
|
2713
|
+
* The list stamps a conversation just after storing its message, so its time
|
|
2714
|
+
* trails the message's own by a few milliseconds, never more than `LIST_LAG_MS`.
|
|
2715
|
+
* A sent message is timed by the phone instead: a little drift either way
|
|
2716
|
+
* still matches, but only when the list rules out a team reply the visitor
|
|
2717
|
+
* hasn't read. Older Workstation deployments don't send `unreadCount`, so
|
|
2718
|
+
* there a sent message must match like any other.
|
|
2719
|
+
*/
|
|
2720
|
+
function describesSameMessage(conversation, known) {
|
|
2721
|
+
const lag = Date.parse(conversation.lastMessageAt ?? '') - Date.parse(known.at);
|
|
2722
|
+
if (lag >= 0 && lag <= LIST_LAG_MS) return true;
|
|
2723
|
+
return !!known.phoneClock && Math.abs(lag) <= SAME_MESSAGE_DRIFT_MS && conversation.unreadCount === 0;
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2386
2726
|
/** A deletion missed while the stream was down shows up on the next re-read. */
|
|
2387
2727
|
function adoptServerDeletions(messages, server) {
|
|
2388
2728
|
const deleted = new Map(server.filter(message => message.deletedAt).map(message => [message.id, message.deletedAt]));
|
|
@@ -2408,17 +2748,100 @@ function adoptServerAttachments(messages, server) {
|
|
|
2408
2748
|
let changed = false;
|
|
2409
2749
|
const next = messages.map(message => {
|
|
2410
2750
|
const copy = serverCopies.get(message.id);
|
|
2411
|
-
|
|
2412
|
-
|
|
2751
|
+
if (!copy || !hasLocalAttachments(message)) return message;
|
|
2752
|
+
const attachments = mergeAttachments(message.attachments, copy.attachments);
|
|
2753
|
+
if (attachments === message.attachments) return message;
|
|
2413
2754
|
changed = true;
|
|
2414
2755
|
return {
|
|
2415
2756
|
...message,
|
|
2416
|
-
attachments
|
|
2757
|
+
attachments
|
|
2417
2758
|
};
|
|
2418
2759
|
});
|
|
2419
2760
|
return changed ? next : messages;
|
|
2420
2761
|
}
|
|
2421
2762
|
|
|
2763
|
+
/**
|
|
2764
|
+
* Each attachment's server copy, except one without a usable URL: that would
|
|
2765
|
+
* only blank its local preview, so the preview stays until a later copy has it.
|
|
2766
|
+
* Returns `local` itself when nothing changes.
|
|
2767
|
+
*/
|
|
2768
|
+
function mergeAttachments(local, server) {
|
|
2769
|
+
// Without a one-to-one match, only a fully usable server copy is safe to take.
|
|
2770
|
+
if (local.length !== server.length) {
|
|
2771
|
+
return server.every(attachment => attachment.url) ? server : local;
|
|
2772
|
+
}
|
|
2773
|
+
const owns = pairByName(local, server);
|
|
2774
|
+
const merged = server.map((attachment, index) => {
|
|
2775
|
+
const own = owns[index];
|
|
2776
|
+
if (!isLocalAttachment(own) || !attachment.url) return own;
|
|
2777
|
+
(0, _localPreviews.rememberLocalPreview)(attachment.url, own.url);
|
|
2778
|
+
return attachment;
|
|
2779
|
+
});
|
|
2780
|
+
return merged.every((attachment, index) => attachment === local[index]) ? local : merged;
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
/**
|
|
2784
|
+
* The local attachment each server one replaces. By name first: Workstation
|
|
2785
|
+
* may store a message's files in another order than they were sent, and a
|
|
2786
|
+
* photo paired with the wrong file would show it for the rest of the session.
|
|
2787
|
+
* Any it renamed pair up in the order left.
|
|
2788
|
+
*/
|
|
2789
|
+
function pairByName(local, server) {
|
|
2790
|
+
const unpaired = [...local];
|
|
2791
|
+
const named = server.map(attachment => {
|
|
2792
|
+
const index = unpaired.findIndex(own => own.name === attachment.name);
|
|
2793
|
+
return index < 0 ? null : unpaired.splice(index, 1)[0];
|
|
2794
|
+
});
|
|
2795
|
+
return named.map(own => own ?? unpaired.shift());
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
/**
|
|
2799
|
+
* Newer Workstation deployments echo `clientMessageId` on the visitor's own
|
|
2800
|
+
* rows. Such a row replaces the pending or failed bubble it came from instead
|
|
2801
|
+
* of showing beside it: no duplicate while a send is in flight, and no retry
|
|
2802
|
+
* for a message that was stored although its response was lost.
|
|
2803
|
+
*/
|
|
2804
|
+
function adoptOwnServerRows(messages, server) {
|
|
2805
|
+
const rows = new Map();
|
|
2806
|
+
server.forEach(row => {
|
|
2807
|
+
if (typeof row.clientMessageId === 'string' && row.clientMessageId) rows.set(row.clientMessageId, row);
|
|
2808
|
+
});
|
|
2809
|
+
if (rows.size === 0) return messages;
|
|
2810
|
+
const shown = new Set(messages.map(message => message.id));
|
|
2811
|
+
let changed = false;
|
|
2812
|
+
const next = [];
|
|
2813
|
+
messages.forEach(message => {
|
|
2814
|
+
const row = (0, _conversations.isLocalMessage)(message) ? rows.get(message.id) : undefined;
|
|
2815
|
+
if (!row) {
|
|
2816
|
+
next.push(message);
|
|
2817
|
+
return;
|
|
2818
|
+
}
|
|
2819
|
+
changed = true;
|
|
2820
|
+
// Already on screen under its server id (an earlier echo): drop the bubble.
|
|
2821
|
+
if (shown.has(row.id)) return;
|
|
2822
|
+
next.push({
|
|
2823
|
+
...row,
|
|
2824
|
+
attachments: row.attachments && message.attachments ? mergeAttachments(message.attachments, row.attachments) : row.attachments ?? message.attachments
|
|
2825
|
+
});
|
|
2826
|
+
});
|
|
2827
|
+
return changed ? next : messages;
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
/** Still the picked file (a `file://` URI, or no URL at all), not the uploaded copy. */
|
|
2831
|
+
function isLocalAttachment(attachment) {
|
|
2832
|
+
return !/^https?:\/\//i.test(attachment.url);
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
/** A visitor bubble still showing the picked files rather than the uploaded copies. */
|
|
2836
|
+
function hasLocalAttachments(message) {
|
|
2837
|
+
return !!message.attachments?.some(isLocalAttachment);
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
/** A sent bubble whose stored copy hasn't replaced the picked files yet. */
|
|
2841
|
+
function isAwaitingServerCopy(message) {
|
|
2842
|
+
return !(0, _conversations.isLocalMessage)(message) && hasLocalAttachments(message);
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2422
2845
|
/** Stable, so same-instant rows keep their order; bad dates sort as oldest. */
|
|
2423
2846
|
function sortByCreatedAt(messages) {
|
|
2424
2847
|
const time = message => Date.parse(message.createdAt) || 0;
|