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