@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
|
@@ -10,13 +10,22 @@
|
|
|
10
10
|
|
|
11
11
|
import { AppState, type AppStateStatus, Platform } from 'react-native';
|
|
12
12
|
|
|
13
|
-
import { isTerminalErrorCode, RepzoWidgetError, WidgetApiClient } from '../api/client';
|
|
13
|
+
import { isTerminalErrorCode, RepzoWidgetError, WidgetApiClient, type UploadedFile } from '../api/client';
|
|
14
14
|
import { WidgetRealtime } from '../api/realtime';
|
|
15
15
|
import { getDeviceId, rotateDeviceId } from '../device-id';
|
|
16
16
|
import { EventEmitter, RepzoWidgetEvents } from '../events';
|
|
17
17
|
import { PushManager } from '../push/push-notifications';
|
|
18
|
-
import {
|
|
19
|
-
|
|
18
|
+
import {
|
|
19
|
+
CONFIG_KEY,
|
|
20
|
+
LAST_MESSAGE_KEY,
|
|
21
|
+
LEGACY_CONFIG_KEY,
|
|
22
|
+
SecureStorage,
|
|
23
|
+
Storage,
|
|
24
|
+
UNREAD_KEY,
|
|
25
|
+
USER_KEY,
|
|
26
|
+
} from '../storage';
|
|
27
|
+
import { clearLocalPreviews, rememberLocalPreview } from '../attachments/local-previews';
|
|
28
|
+
import { guessContentType, isImageAttachment, setAttachmentOptions } from '../attachments/pickers';
|
|
20
29
|
import { DEFAULT_BASE_URL } from '../types';
|
|
21
30
|
import type {
|
|
22
31
|
AgentUiAction,
|
|
@@ -37,10 +46,13 @@ import type {
|
|
|
37
46
|
WidgetUser,
|
|
38
47
|
WidgetView,
|
|
39
48
|
} from '../types';
|
|
49
|
+
import { clearRetainedState } from '../ui/hooks/use-retained-state';
|
|
50
|
+
import { canRetryMessage, isLocalMessage } from '../ui/utils/conversations';
|
|
51
|
+
import { stripHtml } from '../ui/utils/html';
|
|
40
52
|
import { setOpenUrlHandler } from '../ui/utils/safe-url';
|
|
41
53
|
|
|
42
54
|
/** Sent on every init; must match `package.json` (tests/version.test.js checks). */
|
|
43
|
-
const SDK_VERSION = '@repzo/widget-react-native@1.
|
|
55
|
+
const SDK_VERSION = '@repzo/widget-react-native@1.4.0';
|
|
44
56
|
|
|
45
57
|
/** The platform this session reports — mobile sessions get the Mobile SDKs overrides. */
|
|
46
58
|
function sessionPlatform(): 'ios' | 'android' | 'web' {
|
|
@@ -61,6 +73,12 @@ const TYPING_SETTLE_MS = 3_000;
|
|
|
61
73
|
const TYPING_PING_INTERVAL_MS = 4_000;
|
|
62
74
|
/** Guards against the SSE stream replaying messages after a reconnect. */
|
|
63
75
|
const SEEN_MESSAGE_LIMIT = 100;
|
|
76
|
+
/** Threads whose newest message is remembered across launches. */
|
|
77
|
+
const LAST_MESSAGE_LIMIT = 50;
|
|
78
|
+
/** How far the phone's clock may be from Workstation's for a sent message to still match the list. */
|
|
79
|
+
const SAME_MESSAGE_DRIFT_MS = 120_000;
|
|
80
|
+
/** How far the list's `lastMessageAt` may trail the message's own `createdAt` (7 ms seen live). */
|
|
81
|
+
const LIST_LAG_MS = 1_000;
|
|
64
82
|
/** Logout's server calls are best-effort — a dead network must not block sign-out. */
|
|
65
83
|
const LOGOUT_REQUEST_TIMEOUT_MS = 5_000;
|
|
66
84
|
/** A server that 401s every fresh stream must not spin init (10/min limit). */
|
|
@@ -76,6 +94,8 @@ const MARK_READ_DEBOUNCE_MS = 1_000;
|
|
|
76
94
|
const FOREGROUND_REFRESH_AFTER_MS = 5 * 60_000;
|
|
77
95
|
/** Live events held back while a draft's first send is creating its thread. */
|
|
78
96
|
const DRAFT_EVENT_BUFFER_LIMIT = 50;
|
|
97
|
+
/** Waits between re-reads for a sent file's stored copy while Workstation is still storing it. */
|
|
98
|
+
const SERVER_COPY_RETRY_MS = [2_000, 5_000, 15_000];
|
|
79
99
|
/** How long a post stays up after a reaction, thanking them, before it goes. */
|
|
80
100
|
export const POST_THANKS_MS = 1600;
|
|
81
101
|
|
|
@@ -171,6 +191,10 @@ export class WidgetStore {
|
|
|
171
191
|
|
|
172
192
|
/** `boot({ posts })` — Send posts over the app. */
|
|
173
193
|
private postsEnabled = true;
|
|
194
|
+
/** `boot({ photoAlbums })` — a message's photos share one album in the thread. */
|
|
195
|
+
private photoAlbumsEnabled = true;
|
|
196
|
+
/** `boot({ youPrefix })` — "You:" before the visitor's own newest message in the lists. */
|
|
197
|
+
private youPrefixEnabled = true;
|
|
174
198
|
|
|
175
199
|
/** Posts whose view was reported this session (the server also dedupes per day). */
|
|
176
200
|
private viewedPostIds = new Set<string>();
|
|
@@ -233,8 +257,19 @@ export class WidgetStore {
|
|
|
233
257
|
private visitorTypingActive = false;
|
|
234
258
|
|
|
235
259
|
private seenMessageIds: string[] = [];
|
|
260
|
+
|
|
261
|
+
/** Who sent each thread's newest message and what it carried; see `withLastMessage`. */
|
|
262
|
+
private lastMessages: Record<string, LastMessageHint> = {};
|
|
263
|
+
|
|
264
|
+
private lastMessagesLoaded = false;
|
|
236
265
|
/** Statuses (e.g. "read") that arrived for an id before a send response revealed it. */
|
|
237
266
|
private earlyStatuses = new Map<string, string>();
|
|
267
|
+
/** The agent UI action behind a failed bubble, so Retry runs it again too. */
|
|
268
|
+
private failedActions = new Map<string, AgentUiAction>();
|
|
269
|
+
/** The next re-read for a sent file's stored copy (see `syncServerCopies`). */
|
|
270
|
+
private serverCopyTimer: ReturnType<typeof setTimeout> | null = null;
|
|
271
|
+
private serverCopyRetries = 0;
|
|
272
|
+
private serverCopySyncing = false;
|
|
238
273
|
|
|
239
274
|
/**
|
|
240
275
|
* Identity generation. Bumped whenever boot/login/logout is requested; every
|
|
@@ -312,6 +347,10 @@ export class WidgetStore {
|
|
|
312
347
|
/** Composer identity: explicit opens reset it; a draft receiving its server ID does not. */
|
|
313
348
|
getThreadGeneration = (): number => this.threadGeneration;
|
|
314
349
|
|
|
350
|
+
showsPhotoAlbums = (): boolean => this.photoAlbumsEnabled;
|
|
351
|
+
|
|
352
|
+
showsYouPrefix = (): boolean => this.youPrefixEnabled;
|
|
353
|
+
|
|
315
354
|
private setState(patch: Partial<WidgetState> | ((current: WidgetState) => Partial<WidgetState>)) {
|
|
316
355
|
const previous = this.state;
|
|
317
356
|
const resolved = typeof patch === 'function' ? patch(previous) : patch;
|
|
@@ -385,6 +424,8 @@ export class WidgetStore {
|
|
|
385
424
|
if (options.inAppNotifications !== undefined) {
|
|
386
425
|
this.inAppNotificationsEnabled = options.inAppNotifications !== false;
|
|
387
426
|
}
|
|
427
|
+
if (options.photoAlbums !== undefined) this.photoAlbumsEnabled = options.photoAlbums !== false;
|
|
428
|
+
if (options.youPrefix !== undefined) this.youPrefixEnabled = options.youPrefix !== false;
|
|
388
429
|
if (options.posts !== undefined) {
|
|
389
430
|
const wasEnabled = this.postsEnabled;
|
|
390
431
|
this.postsEnabled = options.posts !== false;
|
|
@@ -546,15 +587,15 @@ export class WidgetStore {
|
|
|
546
587
|
]);
|
|
547
588
|
if (this.isStale(epoch)) return;
|
|
548
589
|
|
|
549
|
-
const conversations = result.conversations.map((conversation) =>
|
|
550
|
-
this.hydrateConversation(conversation)
|
|
551
|
-
);
|
|
552
|
-
|
|
553
590
|
// Server counts win (they include replies pushed while the app was
|
|
554
591
|
// killed); the locally persisted map only covers older Workstation
|
|
555
592
|
// deployments that don't send unreadCount yet.
|
|
556
|
-
const storedUnread = await this.loadUnread();
|
|
593
|
+
const [, storedUnread] = await Promise.all([this.loadLastMessages(epoch), this.loadUnread()]);
|
|
557
594
|
if (this.isStale(epoch)) return;
|
|
595
|
+
const conversations = result.conversations.map((conversation) =>
|
|
596
|
+
this.hydrateConversation(conversation)
|
|
597
|
+
);
|
|
598
|
+
|
|
558
599
|
const unreadByConversation: Record<string, number> = {};
|
|
559
600
|
for (const conversation of conversations) {
|
|
560
601
|
const count =
|
|
@@ -842,11 +883,15 @@ export class WidgetStore {
|
|
|
842
883
|
this.userKeyLoaded = true;
|
|
843
884
|
this.pendingAgentByConversation = {};
|
|
844
885
|
this.seenMessageIds = [];
|
|
886
|
+
this.lastMessages = {};
|
|
887
|
+
this.lastMessagesLoaded = false;
|
|
845
888
|
this.earlyStatuses.clear();
|
|
846
889
|
this.visitorTypingActive = false;
|
|
847
890
|
this.lastTypingPingAt = 0;
|
|
848
891
|
this.launchCardOffered = false;
|
|
849
892
|
this.resetPost();
|
|
893
|
+
clearLocalPreviews();
|
|
894
|
+
clearRetainedState();
|
|
850
895
|
this.setState({ ...USER_SCOPED_STATE });
|
|
851
896
|
|
|
852
897
|
const token = await this.client.getToken();
|
|
@@ -854,6 +899,7 @@ export class WidgetStore {
|
|
|
854
899
|
this.client.clearToken(),
|
|
855
900
|
SecureStorage.removeItem(USER_KEY),
|
|
856
901
|
Storage.removeItem(UNREAD_KEY),
|
|
902
|
+
Storage.removeItem(LAST_MESSAGE_KEY),
|
|
857
903
|
// A new anonymous visitor on this device gets a new tracking identity.
|
|
858
904
|
rotateDeviceId(),
|
|
859
905
|
]);
|
|
@@ -890,7 +936,9 @@ export class WidgetStore {
|
|
|
890
936
|
this.threadGeneration += 1;
|
|
891
937
|
this.conversationCreation = null;
|
|
892
938
|
this.draftEvents = [];
|
|
939
|
+
this.failedActions.clear();
|
|
893
940
|
this.cancelMarkRead();
|
|
941
|
+
this.cancelServerCopySync();
|
|
894
942
|
}
|
|
895
943
|
|
|
896
944
|
private requireBootConfig() {
|
|
@@ -1244,6 +1292,7 @@ export class WidgetStore {
|
|
|
1244
1292
|
metadata: this.hydrateMetadata(data?.metadata || null),
|
|
1245
1293
|
status: 'delivered',
|
|
1246
1294
|
createdAt: data?.createdAt || new Date().toISOString(),
|
|
1295
|
+
...(typeof data?.clientMessageId === 'string' ? { clientMessageId: data.clientMessageId } : {}),
|
|
1247
1296
|
};
|
|
1248
1297
|
|
|
1249
1298
|
const clearsTyping = message.direction === 'outbound' && message.senderType !== 'visitor';
|
|
@@ -1272,6 +1321,7 @@ export class WidgetStore {
|
|
|
1272
1321
|
|
|
1273
1322
|
// Set inside the updater, read after: `setState` runs synchronously.
|
|
1274
1323
|
let countedAsUnread = false;
|
|
1324
|
+
const rowFields = rowFieldsOf(message);
|
|
1275
1325
|
|
|
1276
1326
|
this.setState((current) => {
|
|
1277
1327
|
const conversations = conversationId
|
|
@@ -1279,10 +1329,7 @@ export class WidgetStore {
|
|
|
1279
1329
|
conversation.id === conversationId
|
|
1280
1330
|
? {
|
|
1281
1331
|
...conversation,
|
|
1282
|
-
|
|
1283
|
-
message.messageType === 'message'
|
|
1284
|
-
? message.bodyPlain || conversation.snippet
|
|
1285
|
-
: conversation.snippet,
|
|
1332
|
+
...rowFields,
|
|
1286
1333
|
lastMessageAt: message.createdAt,
|
|
1287
1334
|
messageCount: conversation.messageCount + 1,
|
|
1288
1335
|
}
|
|
@@ -1291,12 +1338,13 @@ export class WidgetStore {
|
|
|
1291
1338
|
: current.conversations;
|
|
1292
1339
|
|
|
1293
1340
|
if (onScreen) {
|
|
1294
|
-
const
|
|
1341
|
+
const shown = adoptOwnServerRows(current.messages, [message]);
|
|
1342
|
+
const alreadyPresent = shown.some((existing) => existing.id === message.id);
|
|
1295
1343
|
return {
|
|
1296
1344
|
conversations,
|
|
1297
1345
|
messages: alreadyPresent
|
|
1298
|
-
? adoptServerAttachments(
|
|
1299
|
-
: [...
|
|
1346
|
+
? adoptServerAttachments(shown, [message])
|
|
1347
|
+
: [...shown, message],
|
|
1300
1348
|
agentTyping: clearsTyping ? false : current.agentTyping,
|
|
1301
1349
|
};
|
|
1302
1350
|
}
|
|
@@ -1323,12 +1371,14 @@ export class WidgetStore {
|
|
|
1323
1371
|
messageId,
|
|
1324
1372
|
senderName: message.senderName || conversation?.agentName || null,
|
|
1325
1373
|
senderAvatar: conversation?.agentAvatar ?? null,
|
|
1326
|
-
preview: (message
|
|
1374
|
+
preview: plainTextOf(message),
|
|
1327
1375
|
hasAttachments: (message.attachments?.length ?? 0) > 0,
|
|
1328
1376
|
},
|
|
1329
1377
|
};
|
|
1330
1378
|
});
|
|
1331
1379
|
|
|
1380
|
+
if (conversationId && rowFields) this.rememberLastMessage(conversationId, message.createdAt, rowFields, 'stream');
|
|
1381
|
+
|
|
1332
1382
|
// The visitor is reading it live — move the server cursor too, or the
|
|
1333
1383
|
// next list/init would badge a reply they already saw.
|
|
1334
1384
|
if ((onScreen || readInPost) && isTeamReply && conversationId) this.scheduleMarkRead(conversationId);
|
|
@@ -1425,10 +1475,116 @@ export class WidgetStore {
|
|
|
1425
1475
|
}
|
|
1426
1476
|
|
|
1427
1477
|
private hydrateConversation(conversation: Conversation): Conversation {
|
|
1428
|
-
return this.applyConversationAgent(
|
|
1478
|
+
return this.applyConversationAgent(
|
|
1479
|
+
this.withLastMessage({
|
|
1480
|
+
...conversation,
|
|
1481
|
+
agentAvatar: this.client.resolveUrl(conversation.agentAvatar),
|
|
1482
|
+
})
|
|
1483
|
+
);
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
/**
|
|
1487
|
+
* Workstation's list doesn't say who sent the newest message, and calls one
|
|
1488
|
+
* without words "2 attachments". While a row still describes the message
|
|
1489
|
+
* this device saw last, both are filled in from it. `snippet` keeps the
|
|
1490
|
+
* list's words: apps read it, and the lists draw "Photo" or "File" from
|
|
1491
|
+
* `lastMessageAttachment` instead.
|
|
1492
|
+
*/
|
|
1493
|
+
private withLastMessage(conversation: Conversation): Conversation {
|
|
1494
|
+
const known = this.lastMessages[conversation.id];
|
|
1495
|
+
if (!known || !describesSameMessage(conversation, known)) return conversation;
|
|
1496
|
+
// A list that says who sent it, or what it carried, is used as is.
|
|
1497
|
+
return {
|
|
1429
1498
|
...conversation,
|
|
1430
|
-
|
|
1499
|
+
lastMessageFromVisitor: conversation.lastMessageFromVisitor ?? known.fromVisitor,
|
|
1500
|
+
lastMessageAttachment:
|
|
1501
|
+
conversation.lastMessageAttachment !== undefined ? conversation.lastMessageAttachment : known.attachment,
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
/**
|
|
1506
|
+
* A send or a streamed message is the newest by arrival, whatever the two
|
|
1507
|
+
* clocks say; a history page only replaces something older. A sent message
|
|
1508
|
+
* is timed by the phone, which may run ahead of Workstation, so instead a
|
|
1509
|
+
* page that has it replaces it: whatever is newest there came after it, and
|
|
1510
|
+
* Workstation's own time then stands in for the phone's.
|
|
1511
|
+
*/
|
|
1512
|
+
private rememberLastMessage(
|
|
1513
|
+
conversationId: string,
|
|
1514
|
+
at: string,
|
|
1515
|
+
fields: LastMessageFields,
|
|
1516
|
+
source: 'sent' | 'stream' | 'history',
|
|
1517
|
+
{ messageId, pageIds }: { messageId?: string; pageIds?: ReadonlySet<string> } = {}
|
|
1518
|
+
): void {
|
|
1519
|
+
const known = this.lastMessages[conversationId];
|
|
1520
|
+
if (source === 'history' && known) {
|
|
1521
|
+
const outdated =
|
|
1522
|
+
known.phoneClock && known.messageId
|
|
1523
|
+
? !pageIds?.has(known.messageId)
|
|
1524
|
+
: Date.parse(known.at) > Date.parse(at);
|
|
1525
|
+
if (outdated) return;
|
|
1526
|
+
}
|
|
1527
|
+
const hint: LastMessageHint = {
|
|
1528
|
+
at,
|
|
1529
|
+
fromVisitor: !!fields.lastMessageFromVisitor,
|
|
1530
|
+
attachment: fields.lastMessageAttachment ?? null,
|
|
1531
|
+
...(source === 'sent' ? { phoneClock: true, ...(messageId ? { messageId } : {}) } : {}),
|
|
1532
|
+
};
|
|
1533
|
+
this.lastMessages = Object.fromEntries(
|
|
1534
|
+
Object.entries({ ...this.lastMessages, [conversationId]: hint })
|
|
1535
|
+
.sort(([, a], [, b]) => Date.parse(b.at) - Date.parse(a.at))
|
|
1536
|
+
.slice(0, LAST_MESSAGE_LIMIT)
|
|
1537
|
+
);
|
|
1538
|
+
// Until the stored hints are read, saving would overwrite them with these alone.
|
|
1539
|
+
if (this.lastMessagesLoaded) void Storage.setItem(LAST_MESSAGE_KEY, JSON.stringify(this.lastMessages));
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
private async loadLastMessages(epoch: number): Promise<void> {
|
|
1543
|
+
if (this.lastMessagesLoaded) return;
|
|
1544
|
+
let stored: unknown = null;
|
|
1545
|
+
try {
|
|
1546
|
+
stored = JSON.parse((await Storage.getItem(LAST_MESSAGE_KEY)) ?? 'null');
|
|
1547
|
+
} catch {
|
|
1548
|
+
stored = null;
|
|
1549
|
+
}
|
|
1550
|
+
// Left unloaded: the init that replaces this one reads them again.
|
|
1551
|
+
if (this.isStale(epoch) || this.lastMessagesLoaded) return;
|
|
1552
|
+
this.lastMessagesLoaded = true;
|
|
1553
|
+
const learned = Object.keys(this.lastMessages).length > 0;
|
|
1554
|
+
if (stored && typeof stored === 'object') {
|
|
1555
|
+
for (const [conversationId, hint] of Object.entries(stored as Record<string, Partial<LastMessageHint>>)) {
|
|
1556
|
+
if (typeof hint?.at !== 'string' || typeof hint.fromVisitor !== 'boolean') continue;
|
|
1557
|
+
const known = this.lastMessages[conversationId];
|
|
1558
|
+
if (known && Date.parse(known.at) >= Date.parse(hint.at)) continue;
|
|
1559
|
+
this.lastMessages[conversationId] = {
|
|
1560
|
+
at: hint.at,
|
|
1561
|
+
fromVisitor: hint.fromVisitor,
|
|
1562
|
+
attachment: hint.attachment === 'photo' || hint.attachment === 'file' ? hint.attachment : null,
|
|
1563
|
+
...(hint.phoneClock === true ? { phoneClock: true } : {}),
|
|
1564
|
+
...(typeof hint.messageId === 'string' ? { messageId: hint.messageId } : {}),
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
// Hints learned while the stored ones were being read are saved with them.
|
|
1569
|
+
if (learned) void Storage.setItem(LAST_MESSAGE_KEY, JSON.stringify(this.lastMessages));
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
/** A thread's newest page shows who sent its newest message, so its row can say too. */
|
|
1573
|
+
private learnLastMessage(conversationId: string, page: Message[]): void {
|
|
1574
|
+
let newest: Message | null = null;
|
|
1575
|
+
for (const message of page) {
|
|
1576
|
+
if (!newest || Date.parse(message.createdAt) > Date.parse(newest.createdAt)) newest = message;
|
|
1577
|
+
}
|
|
1578
|
+
const fields = newest && rowFieldsOf(newest);
|
|
1579
|
+
if (!newest || !fields) return;
|
|
1580
|
+
this.rememberLastMessage(conversationId, newest.createdAt, fields, 'history', {
|
|
1581
|
+
pageIds: new Set(page.map((message) => message.id)),
|
|
1431
1582
|
});
|
|
1583
|
+
this.setState((current) => ({
|
|
1584
|
+
conversations: current.conversations.map((row) =>
|
|
1585
|
+
row.id === conversationId ? this.withLastMessage(row) : row
|
|
1586
|
+
),
|
|
1587
|
+
}));
|
|
1432
1588
|
}
|
|
1433
1589
|
|
|
1434
1590
|
/**
|
|
@@ -1439,21 +1595,30 @@ export class WidgetStore {
|
|
|
1439
1595
|
if (!Array.isArray(attachments) || attachments.length === 0) return null;
|
|
1440
1596
|
const hydrated = attachments
|
|
1441
1597
|
.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
|
|
1442
|
-
.map((attachment, index) =>
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1598
|
+
.map((attachment, index) => {
|
|
1599
|
+
const name =
|
|
1600
|
+
typeof attachment.name === 'string' && attachment.name ? attachment.name : 'Attachment';
|
|
1601
|
+
const declared = typeof attachment.contentType === 'string' ? attachment.contentType : '';
|
|
1602
|
+
return {
|
|
1603
|
+
...attachment,
|
|
1604
|
+
id:
|
|
1605
|
+
typeof attachment.id === 'string' || typeof attachment.id === 'number'
|
|
1606
|
+
? String(attachment.id)
|
|
1607
|
+
: `${messageId ?? 'attachment'}-${index}`,
|
|
1608
|
+
name,
|
|
1609
|
+
// Without a type (or with a generic one) the name says what it is,
|
|
1610
|
+
// so a photo still shows as a photo rather than a file row.
|
|
1611
|
+
contentType:
|
|
1612
|
+
declared && !/^application\/octet-stream$/i.test(declared)
|
|
1613
|
+
? declared
|
|
1614
|
+
: (guessContentType(name) ?? declared),
|
|
1615
|
+
size: typeof attachment.size === 'number' ? attachment.size : 0,
|
|
1616
|
+
// Empty (never the raw value) when the URL isn't http(s) — the preview
|
|
1617
|
+
// then renders the name only, with nothing to open.
|
|
1618
|
+
url:
|
|
1619
|
+
this.client.resolveUrl(typeof attachment.url === 'string' ? attachment.url : null) ?? '',
|
|
1620
|
+
};
|
|
1621
|
+
});
|
|
1457
1622
|
return hydrated.length > 0 ? hydrated : null;
|
|
1458
1623
|
}
|
|
1459
1624
|
|
|
@@ -1694,19 +1859,21 @@ export class WidgetStore {
|
|
|
1694
1859
|
const result = await this.client.listMessages(conversationId);
|
|
1695
1860
|
// Opening A then B quickly: A's page must never land under B.
|
|
1696
1861
|
if (this.isStale(epoch) || this.threadGeneration !== generation) return;
|
|
1862
|
+
const page = this.hydrateMessages(result.data);
|
|
1697
1863
|
this.setState((current) => {
|
|
1698
1864
|
// Live replies and optimistic sends may have arrived since this page
|
|
1699
1865
|
// was requested. Keep them, preferring the live copy for shared ids.
|
|
1700
|
-
const
|
|
1701
|
-
const
|
|
1866
|
+
const live = adoptOwnServerRows(current.messages, page);
|
|
1867
|
+
const liveIds = new Set(live.map((message) => message.id));
|
|
1702
1868
|
const history = page.filter((message) => !liveIds.has(message.id));
|
|
1703
1869
|
return {
|
|
1704
|
-
messages: sortByCreatedAt([...history, ...adoptServerAttachments(
|
|
1870
|
+
messages: sortByCreatedAt([...history, ...adoptServerAttachments(live, page)]),
|
|
1705
1871
|
messagesLoading: false,
|
|
1706
1872
|
hasMoreMessages: result.meta.hasMore,
|
|
1707
1873
|
messageCursor: result.meta.nextCursor,
|
|
1708
1874
|
};
|
|
1709
1875
|
});
|
|
1876
|
+
this.learnLastMessage(conversationId, page);
|
|
1710
1877
|
} catch {
|
|
1711
1878
|
if (!this.isStale(epoch) && this.threadGeneration === generation) {
|
|
1712
1879
|
this.setState({ messagesLoading: false });
|
|
@@ -1764,17 +1931,19 @@ export class WidgetStore {
|
|
|
1764
1931
|
if (this.state.activeConversationId !== conversationId) return;
|
|
1765
1932
|
|
|
1766
1933
|
const fresh = this.hydrateMessages(result.data);
|
|
1934
|
+
this.learnLastMessage(conversationId, fresh);
|
|
1767
1935
|
this.setState((current) => {
|
|
1768
|
-
const
|
|
1936
|
+
const own = adoptOwnServerRows(current.messages, fresh);
|
|
1937
|
+
const known = new Set(own.map((message) => message.id));
|
|
1769
1938
|
const missing = fresh.filter((message) => !known.has(message.id));
|
|
1770
1939
|
const upgraded = adoptServerStatuses(
|
|
1771
|
-
adoptServerDeletions(adoptServerAttachments(
|
|
1940
|
+
adoptServerDeletions(adoptServerAttachments(own, fresh), fresh),
|
|
1772
1941
|
fresh
|
|
1773
1942
|
);
|
|
1774
1943
|
if (missing.length === 0) return upgraded === current.messages ? {} : { messages: upgraded };
|
|
1775
1944
|
|
|
1776
|
-
const local =
|
|
1777
|
-
const hasServerMessages = local.length <
|
|
1945
|
+
const local = own.filter(isLocalMessage);
|
|
1946
|
+
const hasServerMessages = local.length < own.length;
|
|
1778
1947
|
// No overlap means the gap is longer than a page: restart from the
|
|
1779
1948
|
// newest page (keeping unsent bubbles) rather than leave a hole.
|
|
1780
1949
|
if (missing.length === fresh.length || !hasServerMessages) {
|
|
@@ -1795,6 +1964,42 @@ export class WidgetStore {
|
|
|
1795
1964
|
}
|
|
1796
1965
|
}
|
|
1797
1966
|
|
|
1967
|
+
/**
|
|
1968
|
+
* Until its stored copy replaces it, a sent bubble points at the visitor's
|
|
1969
|
+
* local files: a photo still opens in the viewer, but a PDF has nothing to
|
|
1970
|
+
* open. When the stream's echo hasn't brought that copy, re-read the
|
|
1971
|
+
* thread, and try again a few times while Workstation is still storing it.
|
|
1972
|
+
*/
|
|
1973
|
+
private async syncServerCopies(): Promise<void> {
|
|
1974
|
+
this.cancelServerCopySync();
|
|
1975
|
+
if (this.serverCopySyncing || !this.state.messages.some(isAwaitingServerCopy)) return;
|
|
1976
|
+
// A send still in flight would come back as a stored row beside its own
|
|
1977
|
+
// pending bubble. It syncs again when it settles.
|
|
1978
|
+
if (this.state.messages.some((message) => isLocalMessage(message) && message.status === 'pending')) return;
|
|
1979
|
+
// Off screen: the thread reloads in full when it's opened again.
|
|
1980
|
+
const conversationId = this.state.activeConversationId;
|
|
1981
|
+
if (!conversationId || !this.isThreadOnScreen(conversationId)) return;
|
|
1982
|
+
|
|
1983
|
+
const generation = this.threadGeneration;
|
|
1984
|
+
this.serverCopySyncing = true;
|
|
1985
|
+
try {
|
|
1986
|
+
// A no-op while a page load is in flight; the retry below covers it.
|
|
1987
|
+
await this.refreshOpenThread();
|
|
1988
|
+
} finally {
|
|
1989
|
+
this.serverCopySyncing = false;
|
|
1990
|
+
}
|
|
1991
|
+
if (this.threadGeneration !== generation || !this.state.messages.some(isAwaitingServerCopy)) return;
|
|
1992
|
+
const delay = SERVER_COPY_RETRY_MS[this.serverCopyRetries];
|
|
1993
|
+
if (delay === undefined) return;
|
|
1994
|
+
this.serverCopyRetries += 1;
|
|
1995
|
+
this.serverCopyTimer = setTimeout(() => void this.syncServerCopies(), delay);
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
private cancelServerCopySync(): void {
|
|
1999
|
+
if (this.serverCopyTimer) clearTimeout(this.serverCopyTimer);
|
|
2000
|
+
this.serverCopyTimer = null;
|
|
2001
|
+
}
|
|
2002
|
+
|
|
1798
2003
|
/** A history page (newest-first) as the oldest-first list the UI renders. */
|
|
1799
2004
|
private hydrateMessages(page: Message[]): Message[] {
|
|
1800
2005
|
return page
|
|
@@ -1839,9 +2044,11 @@ export class WidgetStore {
|
|
|
1839
2044
|
|
|
1840
2045
|
/**
|
|
1841
2046
|
* Rejects when the message wasn't sent, so the composer can keep
|
|
1842
|
-
* attachments for a retry. A text-only bubble is marked failed
|
|
1843
|
-
* attachments is removed instead — the
|
|
1844
|
-
* keeping both would leave a duplicate
|
|
2047
|
+
* attachments for a retry. A text-only bubble is marked failed and can be
|
|
2048
|
+
* re-sent with `retryMessage`; one with attachments is removed instead — the
|
|
2049
|
+
* composer's chips are the retry, and keeping both would leave a duplicate
|
|
2050
|
+
* after a successful resend. Likewise an agent UI action replaces the
|
|
2051
|
+
* failed bubble of the same action, since its card stays tappable.
|
|
1845
2052
|
*/
|
|
1846
2053
|
async sendMessage(
|
|
1847
2054
|
body: string,
|
|
@@ -1852,7 +2059,6 @@ export class WidgetStore {
|
|
|
1852
2059
|
if (!trimmed && attachments.length === 0) return;
|
|
1853
2060
|
|
|
1854
2061
|
const { visitorProfile } = this.state;
|
|
1855
|
-
const generation = this.threadGeneration;
|
|
1856
2062
|
const tempId = `temp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1857
2063
|
const optimistic: Message = {
|
|
1858
2064
|
id: tempId,
|
|
@@ -1883,8 +2089,17 @@ export class WidgetStore {
|
|
|
1883
2089
|
createdAt: new Date().toISOString(),
|
|
1884
2090
|
};
|
|
1885
2091
|
|
|
2092
|
+
// Tapping the card again is the retry: offering the old bubble's retry
|
|
2093
|
+
// too would run the action twice (two items in the cart, two bookings).
|
|
2094
|
+
const superseded = agentUiAction
|
|
2095
|
+
? [...this.failedActions]
|
|
2096
|
+
.filter(([, failed]) => failed.id === agentUiAction.id && failed.type === agentUiAction.type)
|
|
2097
|
+
.map(([messageId]) => messageId)
|
|
2098
|
+
: [];
|
|
2099
|
+
superseded.forEach((messageId) => this.failedActions.delete(messageId));
|
|
2100
|
+
|
|
1886
2101
|
this.setState((current) => ({
|
|
1887
|
-
messages: [...current.messages, optimistic],
|
|
2102
|
+
messages: [...current.messages.filter((message) => !superseded.includes(message.id)), optimistic],
|
|
1888
2103
|
prefillText: '',
|
|
1889
2104
|
}));
|
|
1890
2105
|
this.track('widget_message_sent', {
|
|
@@ -1892,7 +2107,52 @@ export class WidgetStore {
|
|
|
1892
2107
|
attachmentCount: attachments.length,
|
|
1893
2108
|
});
|
|
1894
2109
|
|
|
1895
|
-
|
|
2110
|
+
return this.deliverMessage(tempId, trimmed, attachments, agentUiAction);
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
/**
|
|
2114
|
+
* Puts a picked file in storage while the visitor is still writing, so
|
|
2115
|
+
* Send goes out at once. Resolves the token `sendMessage` names it by, and
|
|
2116
|
+
* when that token expires.
|
|
2117
|
+
*/
|
|
2118
|
+
uploadAttachment(
|
|
2119
|
+
file: { uri: string; name: string; contentType: string; size: number },
|
|
2120
|
+
signal?: AbortSignal
|
|
2121
|
+
): Promise<UploadedFile> {
|
|
2122
|
+
return this.client.uploadAttachment(file, signal);
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
/**
|
|
2126
|
+
* Re-sends a text bubble that failed. It moves to the end of the thread,
|
|
2127
|
+
* since it now goes out after anything sent meanwhile. Rejects like
|
|
2128
|
+
* `sendMessage` when it fails again; the bubble is then failed once more.
|
|
2129
|
+
*/
|
|
2130
|
+
async retryMessage(messageId: string): Promise<void> {
|
|
2131
|
+
const failed = this.state.messages.find(
|
|
2132
|
+
(message) => message.id === messageId && canRetryMessage(message)
|
|
2133
|
+
);
|
|
2134
|
+
if (!failed) return;
|
|
2135
|
+
const agentUiAction = this.failedActions.get(messageId);
|
|
2136
|
+
this.failedActions.delete(messageId);
|
|
2137
|
+
const pending: Message = { ...failed, status: 'pending', createdAt: new Date().toISOString() };
|
|
2138
|
+
this.setState((current) => ({
|
|
2139
|
+
messages: [...current.messages.filter((message) => message.id !== messageId), pending],
|
|
2140
|
+
}));
|
|
2141
|
+
return this.deliverMessage(messageId, failed.bodyPlain ?? '', [], agentUiAction);
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
/** Sends the optimistic bubble `tempId`, already on screen as pending. */
|
|
2145
|
+
private async deliverMessage(
|
|
2146
|
+
tempId: string,
|
|
2147
|
+
trimmed: string,
|
|
2148
|
+
attachments: OutgoingAttachment[],
|
|
2149
|
+
agentUiAction: AgentUiAction | undefined
|
|
2150
|
+
): Promise<void> {
|
|
2151
|
+
const { visitorProfile } = this.state;
|
|
2152
|
+
const generation = this.threadGeneration;
|
|
2153
|
+
|
|
2154
|
+
const markFailed = () => {
|
|
2155
|
+
if (agentUiAction && attachments.length === 0) this.failedActions.set(tempId, agentUiAction);
|
|
1896
2156
|
this.setState((current) => ({
|
|
1897
2157
|
messages:
|
|
1898
2158
|
attachments.length > 0
|
|
@@ -1901,6 +2161,7 @@ export class WidgetStore {
|
|
|
1901
2161
|
message.id === tempId ? { ...message, status: 'failed' } : message
|
|
1902
2162
|
),
|
|
1903
2163
|
}));
|
|
2164
|
+
};
|
|
1904
2165
|
|
|
1905
2166
|
const epoch = this.epoch;
|
|
1906
2167
|
|
|
@@ -1928,26 +2189,43 @@ export class WidgetStore {
|
|
|
1928
2189
|
|
|
1929
2190
|
let createdId: string | null = null;
|
|
1930
2191
|
try {
|
|
2192
|
+
// Local files go up first, unless the composer already put them up; the
|
|
2193
|
+
// message names each by its upload token.
|
|
2194
|
+
const sentAttachments = await Promise.all(
|
|
2195
|
+
attachments.map(async ({ name, url, fileUri, uploadToken, contentType, size }) =>
|
|
2196
|
+
uploadToken
|
|
2197
|
+
? { name, contentType, size, uploadToken }
|
|
2198
|
+
: fileUri
|
|
2199
|
+
? {
|
|
2200
|
+
name,
|
|
2201
|
+
contentType,
|
|
2202
|
+
size,
|
|
2203
|
+
uploadToken: (await this.client.uploadAttachment({ uri: fileUri, name, contentType, size }))
|
|
2204
|
+
.uploadToken,
|
|
2205
|
+
}
|
|
2206
|
+
: { name, url, contentType, size }
|
|
2207
|
+
)
|
|
2208
|
+
);
|
|
2209
|
+
if (this.isStale(epoch)) return;
|
|
1931
2210
|
const result = await this.client.sendMessage({
|
|
1932
2211
|
conversationId: conversationId || undefined,
|
|
1933
2212
|
forceNewConversation: !conversationId,
|
|
1934
2213
|
body: trimmed,
|
|
1935
|
-
attachments:
|
|
1936
|
-
attachments.length > 0
|
|
1937
|
-
? attachments.map(({ name, url, contentType, size }) => ({
|
|
1938
|
-
name,
|
|
1939
|
-
url,
|
|
1940
|
-
contentType,
|
|
1941
|
-
size,
|
|
1942
|
-
}))
|
|
1943
|
-
: undefined,
|
|
2214
|
+
attachments: sentAttachments.length > 0 ? sentAttachments : undefined,
|
|
1944
2215
|
visitorName: visitorProfile.name,
|
|
1945
2216
|
visitorEmail: visitorProfile.email,
|
|
1946
2217
|
agentUiAction,
|
|
2218
|
+
// The bubble's id, the same on every retry: a newer Workstation stores
|
|
2219
|
+
// the message once even when an earlier attempt's response was lost.
|
|
2220
|
+
clientMessageId: tempId,
|
|
1947
2221
|
});
|
|
1948
2222
|
if (this.isStale(epoch)) return;
|
|
1949
2223
|
if (ownCreation) createdId = result.conversationId;
|
|
1950
2224
|
|
|
2225
|
+
// Workstation doesn't stream the visitor's own messages back, so the
|
|
2226
|
+
// list row learns about this one here.
|
|
2227
|
+
const sentAt = new Date().toISOString();
|
|
2228
|
+
const sentRow = rowFieldsFor(trimmed, attachments.map((attachment) => attachment.contentType), true);
|
|
1951
2229
|
this.setState((current) => {
|
|
1952
2230
|
// The stream or a catch-up fetch may have delivered this message
|
|
1953
2231
|
// already — keep one copy, never two bubbles with the same id.
|
|
@@ -1960,19 +2238,27 @@ export class WidgetStore {
|
|
|
1960
2238
|
: message
|
|
1961
2239
|
);
|
|
1962
2240
|
|
|
1963
|
-
if (!createdId)
|
|
2241
|
+
if (!createdId) {
|
|
2242
|
+
return {
|
|
2243
|
+
messages,
|
|
2244
|
+
conversations: current.conversations.map((row) =>
|
|
2245
|
+
row.id === result.conversationId
|
|
2246
|
+
? { ...row, ...sentRow, lastMessageAt: sentAt, messageCount: row.messageCount + (delivered ? 0 : 1) }
|
|
2247
|
+
: row
|
|
2248
|
+
),
|
|
2249
|
+
};
|
|
2250
|
+
}
|
|
1964
2251
|
|
|
1965
2252
|
const known = current.conversations.some((item) => item.id === result.conversationId);
|
|
1966
2253
|
const conversation: Conversation = {
|
|
1967
2254
|
id: result.conversationId,
|
|
1968
2255
|
subject: null,
|
|
1969
2256
|
status: 'open',
|
|
1970
|
-
snippet:
|
|
1971
|
-
|
|
1972
|
-
: `${attachments.length} attachment${attachments.length === 1 ? '' : 's'}`,
|
|
2257
|
+
snippet: null,
|
|
2258
|
+
...sentRow,
|
|
1973
2259
|
messageCount: 1,
|
|
1974
|
-
lastMessageAt:
|
|
1975
|
-
createdAt:
|
|
2260
|
+
lastMessageAt: sentAt,
|
|
2261
|
+
createdAt: sentAt,
|
|
1976
2262
|
agentName: null,
|
|
1977
2263
|
agentAvatar: null,
|
|
1978
2264
|
};
|
|
@@ -1988,8 +2274,16 @@ export class WidgetStore {
|
|
|
1988
2274
|
: {}),
|
|
1989
2275
|
};
|
|
1990
2276
|
});
|
|
2277
|
+
this.rememberLastMessage(result.conversationId, sentAt, sentRow, 'sent', { messageId: result.messageId });
|
|
2278
|
+
|
|
2279
|
+
// New files to fetch the stored copy of: a fresh round of re-reads.
|
|
2280
|
+
const sent = this.state.messages.find((message) => message.id === result.messageId);
|
|
2281
|
+
if (sent && hasLocalAttachments(sent)) this.serverCopyRetries = 0;
|
|
1991
2282
|
} catch (error) {
|
|
1992
2283
|
if (this.isStale(epoch)) throw error;
|
|
2284
|
+
// The response was lost, but its stored row has already taken the
|
|
2285
|
+
// bubble's place (newer Workstation deployments): it was sent.
|
|
2286
|
+
if (this.state.messages.some((message) => message.clientMessageId === tempId)) return;
|
|
1993
2287
|
markFailed();
|
|
1994
2288
|
throw error;
|
|
1995
2289
|
} finally {
|
|
@@ -2000,6 +2294,8 @@ export class WidgetStore {
|
|
|
2000
2294
|
// Anything held back for the draft now has a home (or goes to unread).
|
|
2001
2295
|
this.replayDraftEvents();
|
|
2002
2296
|
}
|
|
2297
|
+
// Waits for every send to settle, so this runs when the last one does.
|
|
2298
|
+
if (!this.isStale(epoch)) void this.syncServerCopies();
|
|
2003
2299
|
}
|
|
2004
2300
|
}
|
|
2005
2301
|
|
|
@@ -2462,11 +2758,6 @@ function createConversationCreation(generation: number): ConversationCreation {
|
|
|
2462
2758
|
return { generation, promise, resolve };
|
|
2463
2759
|
}
|
|
2464
2760
|
|
|
2465
|
-
/** Optimistic visitor bubbles the server hasn't acknowledged (pending/failed). */
|
|
2466
|
-
function isLocalMessage(message: Message): boolean {
|
|
2467
|
-
return message.id.startsWith('temp-');
|
|
2468
|
-
}
|
|
2469
|
-
|
|
2470
2761
|
/** A "Seen" missed while the stream was down shows up on the next re-read. */
|
|
2471
2762
|
function adoptServerStatuses(messages: Message[], server: Message[]): Message[] {
|
|
2472
2763
|
const seen = new Set(server.filter((message) => message.status === 'read').map((message) => message.id));
|
|
@@ -2485,6 +2776,77 @@ function asDeletedPlaceholder(message: Message, deletedAt: string): Message {
|
|
|
2485
2776
|
return { ...message, body: null, bodyPlain: null, attachments: null, metadata: null, deletedAt };
|
|
2486
2777
|
}
|
|
2487
2778
|
|
|
2779
|
+
/** A message's words on one line; replies written in Workstation may only carry HTML. */
|
|
2780
|
+
function plainTextOf(message: Message): string {
|
|
2781
|
+
return (message.bodyPlain || stripHtml(message.body ?? '')).replace(/\s+/g, ' ').trim();
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
/**
|
|
2785
|
+
* The newest message of a thread as this device saw it. `at` is its
|
|
2786
|
+
* `createdAt`, which Workstation's list repeats as `lastMessageAt`; for a sent
|
|
2787
|
+
* message it's the phone's clock instead.
|
|
2788
|
+
*/
|
|
2789
|
+
interface LastMessageHint {
|
|
2790
|
+
at: string;
|
|
2791
|
+
fromVisitor: boolean;
|
|
2792
|
+
attachment: 'photo' | 'file' | null;
|
|
2793
|
+
phoneClock?: boolean;
|
|
2794
|
+
/** A sent message's id: a page from Workstation that has it can retime it. */
|
|
2795
|
+
messageId?: string;
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
/** `snippet` is absent when there's nothing to quote: the row keeps its words. */
|
|
2799
|
+
type LastMessageFields = Pick<Conversation, 'lastMessageAttachment' | 'lastMessageFromVisitor'> & {
|
|
2800
|
+
snippet?: string;
|
|
2801
|
+
};
|
|
2802
|
+
|
|
2803
|
+
/** The list row once `message` is the newest. Null keeps the row as it is: system rows and notes. */
|
|
2804
|
+
function rowFieldsOf(message: Message): LastMessageFields | null {
|
|
2805
|
+
if (message.messageType && message.messageType !== 'message') return null;
|
|
2806
|
+
return rowFieldsFor(
|
|
2807
|
+
plainTextOf(message),
|
|
2808
|
+
(message.attachments ?? []).map((attachment) => attachment.contentType),
|
|
2809
|
+
message.senderType === 'visitor' || message.direction === 'inbound',
|
|
2810
|
+
/<img\b/i.test(message.body ?? '')
|
|
2811
|
+
);
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
/**
|
|
2815
|
+
* Its words, or with none, whether it carried only photos or other files too.
|
|
2816
|
+
* A message with neither (a card) still says who sent it, so a teammate's
|
|
2817
|
+
* card never leaves "You:" on the visitor's earlier message.
|
|
2818
|
+
*/
|
|
2819
|
+
function rowFieldsFor(
|
|
2820
|
+
text: string,
|
|
2821
|
+
contentTypes: Array<string | null | undefined>,
|
|
2822
|
+
fromVisitor: boolean,
|
|
2823
|
+
inlinePhoto = false
|
|
2824
|
+
): LastMessageFields {
|
|
2825
|
+
if (text) return { snippet: text, lastMessageAttachment: null, lastMessageFromVisitor: fromVisitor };
|
|
2826
|
+
const count = contentTypes.length;
|
|
2827
|
+
if (count === 0 && !inlinePhoto) return { lastMessageAttachment: null, lastMessageFromVisitor: fromVisitor };
|
|
2828
|
+
return {
|
|
2829
|
+
// Workstation's list words it this way too, so apps reading `snippet` see the same after a reload.
|
|
2830
|
+
...(count > 0 ? { snippet: `${count} attachment${count === 1 ? '' : 's'}` } : {}),
|
|
2831
|
+
lastMessageAttachment: contentTypes.every((type) => isImageAttachment(type)) ? 'photo' : 'file',
|
|
2832
|
+
lastMessageFromVisitor: fromVisitor,
|
|
2833
|
+
};
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
/**
|
|
2837
|
+
* The list stamps a conversation just after storing its message, so its time
|
|
2838
|
+
* trails the message's own by a few milliseconds, never more than `LIST_LAG_MS`.
|
|
2839
|
+
* A sent message is timed by the phone instead: a little drift either way
|
|
2840
|
+
* still matches, but only when the list rules out a team reply the visitor
|
|
2841
|
+
* hasn't read. Older Workstation deployments don't send `unreadCount`, so
|
|
2842
|
+
* there a sent message must match like any other.
|
|
2843
|
+
*/
|
|
2844
|
+
function describesSameMessage(conversation: Conversation, known: LastMessageHint): boolean {
|
|
2845
|
+
const lag = Date.parse(conversation.lastMessageAt ?? '') - Date.parse(known.at);
|
|
2846
|
+
if (lag >= 0 && lag <= LIST_LAG_MS) return true;
|
|
2847
|
+
return !!known.phoneClock && Math.abs(lag) <= SAME_MESSAGE_DRIFT_MS && conversation.unreadCount === 0;
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2488
2850
|
/** A deletion missed while the stream was down shows up on the next re-read. */
|
|
2489
2851
|
function adoptServerDeletions(messages: Message[], server: Message[]): Message[] {
|
|
2490
2852
|
const deleted = new Map(
|
|
@@ -2514,14 +2876,101 @@ function adoptServerAttachments(messages: Message[], server: Message[]): Message
|
|
|
2514
2876
|
let changed = false;
|
|
2515
2877
|
const next = messages.map((message) => {
|
|
2516
2878
|
const copy = serverCopies.get(message.id);
|
|
2517
|
-
|
|
2518
|
-
|
|
2879
|
+
if (!copy || !hasLocalAttachments(message)) return message;
|
|
2880
|
+
const attachments = mergeAttachments(message.attachments!, copy.attachments!);
|
|
2881
|
+
if (attachments === message.attachments) return message;
|
|
2519
2882
|
changed = true;
|
|
2520
|
-
return { ...message, attachments
|
|
2883
|
+
return { ...message, attachments };
|
|
2521
2884
|
});
|
|
2522
2885
|
return changed ? next : messages;
|
|
2523
2886
|
}
|
|
2524
2887
|
|
|
2888
|
+
/**
|
|
2889
|
+
* Each attachment's server copy, except one without a usable URL: that would
|
|
2890
|
+
* only blank its local preview, so the preview stays until a later copy has it.
|
|
2891
|
+
* Returns `local` itself when nothing changes.
|
|
2892
|
+
*/
|
|
2893
|
+
function mergeAttachments(local: WidgetAttachment[], server: WidgetAttachment[]): WidgetAttachment[] {
|
|
2894
|
+
// Without a one-to-one match, only a fully usable server copy is safe to take.
|
|
2895
|
+
if (local.length !== server.length) {
|
|
2896
|
+
return server.every((attachment) => attachment.url) ? server : local;
|
|
2897
|
+
}
|
|
2898
|
+
const owns = pairByName(local, server);
|
|
2899
|
+
const merged = server.map((attachment, index) => {
|
|
2900
|
+
const own = owns[index]!;
|
|
2901
|
+
if (!isLocalAttachment(own) || !attachment.url) return own;
|
|
2902
|
+
rememberLocalPreview(attachment.url, own.url);
|
|
2903
|
+
return attachment;
|
|
2904
|
+
});
|
|
2905
|
+
return merged.every((attachment, index) => attachment === local[index]) ? local : merged;
|
|
2906
|
+
}
|
|
2907
|
+
|
|
2908
|
+
/**
|
|
2909
|
+
* The local attachment each server one replaces. By name first: Workstation
|
|
2910
|
+
* may store a message's files in another order than they were sent, and a
|
|
2911
|
+
* photo paired with the wrong file would show it for the rest of the session.
|
|
2912
|
+
* Any it renamed pair up in the order left.
|
|
2913
|
+
*/
|
|
2914
|
+
function pairByName(local: WidgetAttachment[], server: WidgetAttachment[]): WidgetAttachment[] {
|
|
2915
|
+
const unpaired = [...local];
|
|
2916
|
+
const named = server.map((attachment) => {
|
|
2917
|
+
const index = unpaired.findIndex((own) => own.name === attachment.name);
|
|
2918
|
+
return index < 0 ? null : unpaired.splice(index, 1)[0]!;
|
|
2919
|
+
});
|
|
2920
|
+
return named.map((own) => own ?? unpaired.shift()!);
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
/**
|
|
2924
|
+
* Newer Workstation deployments echo `clientMessageId` on the visitor's own
|
|
2925
|
+
* rows. Such a row replaces the pending or failed bubble it came from instead
|
|
2926
|
+
* of showing beside it: no duplicate while a send is in flight, and no retry
|
|
2927
|
+
* for a message that was stored although its response was lost.
|
|
2928
|
+
*/
|
|
2929
|
+
function adoptOwnServerRows(messages: Message[], server: Message[]): Message[] {
|
|
2930
|
+
const rows = new Map<string, Message>();
|
|
2931
|
+
server.forEach((row) => {
|
|
2932
|
+
if (typeof row.clientMessageId === 'string' && row.clientMessageId) rows.set(row.clientMessageId, row);
|
|
2933
|
+
});
|
|
2934
|
+
if (rows.size === 0) return messages;
|
|
2935
|
+
|
|
2936
|
+
const shown = new Set(messages.map((message) => message.id));
|
|
2937
|
+
let changed = false;
|
|
2938
|
+
const next: Message[] = [];
|
|
2939
|
+
messages.forEach((message) => {
|
|
2940
|
+
const row = isLocalMessage(message) ? rows.get(message.id) : undefined;
|
|
2941
|
+
if (!row) {
|
|
2942
|
+
next.push(message);
|
|
2943
|
+
return;
|
|
2944
|
+
}
|
|
2945
|
+
changed = true;
|
|
2946
|
+
// Already on screen under its server id (an earlier echo): drop the bubble.
|
|
2947
|
+
if (shown.has(row.id)) return;
|
|
2948
|
+
next.push({
|
|
2949
|
+
...row,
|
|
2950
|
+
attachments:
|
|
2951
|
+
row.attachments && message.attachments
|
|
2952
|
+
? mergeAttachments(message.attachments, row.attachments)
|
|
2953
|
+
: (row.attachments ?? message.attachments),
|
|
2954
|
+
});
|
|
2955
|
+
});
|
|
2956
|
+
return changed ? next : messages;
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
/** Still the picked file (a `file://` URI, or no URL at all), not the uploaded copy. */
|
|
2960
|
+
function isLocalAttachment(attachment: WidgetAttachment): boolean {
|
|
2961
|
+
return !/^https?:\/\//i.test(attachment.url);
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2964
|
+
/** A visitor bubble still showing the picked files rather than the uploaded copies. */
|
|
2965
|
+
function hasLocalAttachments(message: Message): boolean {
|
|
2966
|
+
return !!message.attachments?.some(isLocalAttachment);
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
/** A sent bubble whose stored copy hasn't replaced the picked files yet. */
|
|
2970
|
+
function isAwaitingServerCopy(message: Message): boolean {
|
|
2971
|
+
return !isLocalMessage(message) && hasLocalAttachments(message);
|
|
2972
|
+
}
|
|
2973
|
+
|
|
2525
2974
|
/** Stable, so same-instant rows keep their order; bad dates sort as oldest. */
|
|
2526
2975
|
function sortByCreatedAt(messages: Message[]): Message[] {
|
|
2527
2976
|
const time = (message: Message) => Date.parse(message.createdAt) || 0;
|