@repzo/widget-react-native 1.3.1 → 1.5.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 +173 -5
- package/README.md +99 -13
- package/lib/commonjs/api/client.js +125 -28
- 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 +20 -29
- package/lib/commonjs/attachments/pickers.js.map +1 -1
- package/lib/commonjs/i18n.js +22 -3
- package/lib/commonjs/i18n.js.map +1 -1
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/locales.js +12 -3
- package/lib/commonjs/locales.js.map +1 -1
- package/lib/commonjs/state/user-update-queue.js +168 -0
- package/lib/commonjs/state/user-update-queue.js.map +1 -0
- package/lib/commonjs/state/widget-store.js +303 -33
- 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 +33 -13
- 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 +229 -24
- 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/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 +651 -165
- package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js +138 -1
- 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/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 +123 -27
- 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 +15 -25
- package/lib/module/attachments/pickers.js.map +1 -1
- package/lib/module/i18n.js +22 -3
- package/lib/module/i18n.js.map +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/locales.js +12 -3
- package/lib/module/locales.js.map +1 -1
- package/lib/module/state/user-update-queue.js +163 -0
- package/lib/module/state/user-update-queue.js.map +1 -0
- package/lib/module/state/widget-store.js +305 -35
- 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 +33 -13
- 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 +231 -26
- 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/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 +655 -169
- package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/module/ui/screens/chat/ChatView.styles.js +140 -2
- 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/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 +20 -6
- 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 +15 -2
- package/lib/typescript/commonjs/attachments/pickers.d.ts.map +1 -1
- package/lib/typescript/commonjs/i18n.d.ts +12 -3
- package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +5 -0
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/locales.d.ts.map +1 -1
- package/lib/typescript/commonjs/state/user-update-queue.d.ts +62 -0
- package/lib/typescript/commonjs/state/user-update-queue.d.ts.map +1 -0
- package/lib/typescript/commonjs/state/widget-store.d.ts +44 -1
- 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 +53 -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/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 +144 -0
- 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 +26 -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/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 +20 -6
- 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 +15 -2
- package/lib/typescript/module/attachments/pickers.d.ts.map +1 -1
- package/lib/typescript/module/i18n.d.ts +12 -3
- package/lib/typescript/module/i18n.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +5 -0
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/locales.d.ts.map +1 -1
- package/lib/typescript/module/state/user-update-queue.d.ts +62 -0
- package/lib/typescript/module/state/user-update-queue.d.ts.map +1 -0
- package/lib/typescript/module/state/widget-store.d.ts +44 -1
- 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 +53 -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/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 +144 -0
- 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 +26 -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/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 +149 -38
- package/src/attachments/file-size.ts +42 -0
- package/src/attachments/local-previews.ts +32 -0
- package/src/attachments/pickers.ts +23 -25
- package/src/i18n.ts +22 -3
- package/src/index.tsx +5 -0
- package/src/locales.ts +12 -3
- package/src/state/user-update-queue.ts +178 -0
- package/src/state/widget-store.ts +346 -51
- package/src/storage.ts +2 -0
- package/src/types.ts +53 -0
- package/src/ui/WidgetProvider.tsx +35 -8
- package/src/ui/components/AttachmentChips.tsx +100 -29
- package/src/ui/components/ConversationRow.tsx +90 -9
- package/src/ui/components/ImageViewer.tsx +242 -24
- package/src/ui/components/RatingCard.tsx +5 -2
- package/src/ui/components/ZoomableImage.tsx +58 -5
- package/src/ui/hooks/use-retained-state.ts +35 -0
- package/src/ui/screens/chat/ChatView.styles.ts +139 -1
- package/src/ui/screens/chat/ChatView.tsx +735 -198
- package/src/ui/screens/chat/ChatView.types.ts +26 -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/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,11 +46,14 @@ import type {
|
|
|
37
46
|
WidgetUser,
|
|
38
47
|
WidgetView,
|
|
39
48
|
} from '../types';
|
|
49
|
+
import { clearRetainedState } from '../ui/hooks/use-retained-state';
|
|
40
50
|
import { canRetryMessage, isLocalMessage } from '../ui/utils/conversations';
|
|
51
|
+
import { stripHtml } from '../ui/utils/html';
|
|
41
52
|
import { setOpenUrlHandler } from '../ui/utils/safe-url';
|
|
53
|
+
import { UserUpdateQueue } from './user-update-queue';
|
|
42
54
|
|
|
43
55
|
/** Sent on every init; must match `package.json` (tests/version.test.js checks). */
|
|
44
|
-
const SDK_VERSION = '@repzo/widget-react-native@1.
|
|
56
|
+
const SDK_VERSION = '@repzo/widget-react-native@1.5.0';
|
|
45
57
|
|
|
46
58
|
/** The platform this session reports — mobile sessions get the Mobile SDKs overrides. */
|
|
47
59
|
function sessionPlatform(): 'ios' | 'android' | 'web' {
|
|
@@ -62,6 +74,12 @@ const TYPING_SETTLE_MS = 3_000;
|
|
|
62
74
|
const TYPING_PING_INTERVAL_MS = 4_000;
|
|
63
75
|
/** Guards against the SSE stream replaying messages after a reconnect. */
|
|
64
76
|
const SEEN_MESSAGE_LIMIT = 100;
|
|
77
|
+
/** Threads whose newest message is remembered across launches. */
|
|
78
|
+
const LAST_MESSAGE_LIMIT = 50;
|
|
79
|
+
/** How far the phone's clock may be from Workstation's for a sent message to still match the list. */
|
|
80
|
+
const SAME_MESSAGE_DRIFT_MS = 120_000;
|
|
81
|
+
/** How far the list's `lastMessageAt` may trail the message's own `createdAt` (7 ms seen live). */
|
|
82
|
+
const LIST_LAG_MS = 1_000;
|
|
65
83
|
/** Logout's server calls are best-effort — a dead network must not block sign-out. */
|
|
66
84
|
const LOGOUT_REQUEST_TIMEOUT_MS = 5_000;
|
|
67
85
|
/** A server that 401s every fresh stream must not spin init (10/min limit). */
|
|
@@ -144,6 +162,11 @@ const USER_SCOPED_STATE: Partial<WidgetState> = {
|
|
|
144
162
|
export class WidgetStore {
|
|
145
163
|
readonly client = new WidgetApiClient();
|
|
146
164
|
|
|
165
|
+
/** Paces `updateUser()` — see `boot({ throttleUpdateUser })`. */
|
|
166
|
+
private readonly userUpdates = new UserUpdateQueue((customAttributes) =>
|
|
167
|
+
this.client.identify({ customAttributes })
|
|
168
|
+
);
|
|
169
|
+
|
|
147
170
|
readonly events = new EventEmitter();
|
|
148
171
|
|
|
149
172
|
readonly push = new PushManager(
|
|
@@ -174,6 +197,12 @@ export class WidgetStore {
|
|
|
174
197
|
|
|
175
198
|
/** `boot({ posts })` — Send posts over the app. */
|
|
176
199
|
private postsEnabled = true;
|
|
200
|
+
/** `boot({ photoAlbums })` — a message's photos share one album in the thread. */
|
|
201
|
+
private photoAlbumsEnabled = true;
|
|
202
|
+
/** `boot({ youPrefix })` — "You:" before the visitor's own newest message in the lists. */
|
|
203
|
+
private youPrefixEnabled = true;
|
|
204
|
+
/** `boot({ throttleUpdateUser })` — skip unchanged `updateUser()` values and pace the rest. */
|
|
205
|
+
private throttleUpdateUserEnabled = true;
|
|
177
206
|
|
|
178
207
|
/** Posts whose view was reported this session (the server also dedupes per day). */
|
|
179
208
|
private viewedPostIds = new Set<string>();
|
|
@@ -236,6 +265,11 @@ export class WidgetStore {
|
|
|
236
265
|
private visitorTypingActive = false;
|
|
237
266
|
|
|
238
267
|
private seenMessageIds: string[] = [];
|
|
268
|
+
|
|
269
|
+
/** Who sent each thread's newest message and what it carried; see `withLastMessage`. */
|
|
270
|
+
private lastMessages: Record<string, LastMessageHint> = {};
|
|
271
|
+
|
|
272
|
+
private lastMessagesLoaded = false;
|
|
239
273
|
/** Statuses (e.g. "read") that arrived for an id before a send response revealed it. */
|
|
240
274
|
private earlyStatuses = new Map<string, string>();
|
|
241
275
|
/** The agent UI action behind a failed bubble, so Retry runs it again too. */
|
|
@@ -321,6 +355,10 @@ export class WidgetStore {
|
|
|
321
355
|
/** Composer identity: explicit opens reset it; a draft receiving its server ID does not. */
|
|
322
356
|
getThreadGeneration = (): number => this.threadGeneration;
|
|
323
357
|
|
|
358
|
+
showsPhotoAlbums = (): boolean => this.photoAlbumsEnabled;
|
|
359
|
+
|
|
360
|
+
showsYouPrefix = (): boolean => this.youPrefixEnabled;
|
|
361
|
+
|
|
324
362
|
private setState(patch: Partial<WidgetState> | ((current: WidgetState) => Partial<WidgetState>)) {
|
|
325
363
|
const previous = this.state;
|
|
326
364
|
const resolved = typeof patch === 'function' ? patch(previous) : patch;
|
|
@@ -394,6 +432,11 @@ export class WidgetStore {
|
|
|
394
432
|
if (options.inAppNotifications !== undefined) {
|
|
395
433
|
this.inAppNotificationsEnabled = options.inAppNotifications !== false;
|
|
396
434
|
}
|
|
435
|
+
if (options.photoAlbums !== undefined) this.photoAlbumsEnabled = options.photoAlbums !== false;
|
|
436
|
+
if (options.youPrefix !== undefined) this.youPrefixEnabled = options.youPrefix !== false;
|
|
437
|
+
if (options.throttleUpdateUser !== undefined) {
|
|
438
|
+
this.throttleUpdateUserEnabled = options.throttleUpdateUser !== false;
|
|
439
|
+
}
|
|
397
440
|
if (options.posts !== undefined) {
|
|
398
441
|
const wasEnabled = this.postsEnabled;
|
|
399
442
|
this.postsEnabled = options.posts !== false;
|
|
@@ -555,15 +598,15 @@ export class WidgetStore {
|
|
|
555
598
|
]);
|
|
556
599
|
if (this.isStale(epoch)) return;
|
|
557
600
|
|
|
558
|
-
const conversations = result.conversations.map((conversation) =>
|
|
559
|
-
this.hydrateConversation(conversation)
|
|
560
|
-
);
|
|
561
|
-
|
|
562
601
|
// Server counts win (they include replies pushed while the app was
|
|
563
602
|
// killed); the locally persisted map only covers older Workstation
|
|
564
603
|
// deployments that don't send unreadCount yet.
|
|
565
|
-
const storedUnread = await this.loadUnread();
|
|
604
|
+
const [, storedUnread] = await Promise.all([this.loadLastMessages(epoch), this.loadUnread()]);
|
|
566
605
|
if (this.isStale(epoch)) return;
|
|
606
|
+
const conversations = result.conversations.map((conversation) =>
|
|
607
|
+
this.hydrateConversation(conversation)
|
|
608
|
+
);
|
|
609
|
+
|
|
567
610
|
const unreadByConversation: Record<string, number> = {};
|
|
568
611
|
for (const conversation of conversations) {
|
|
569
612
|
const count =
|
|
@@ -577,6 +620,10 @@ export class WidgetStore {
|
|
|
577
620
|
if (onScreenId && this.isThreadOnScreen(onScreenId)) delete unreadByConversation[onScreenId];
|
|
578
621
|
this.persistUnread(unreadByConversation);
|
|
579
622
|
|
|
623
|
+
// A new identity (anonymous → signed in) may hold other attributes, so
|
|
624
|
+
// nothing is skipped as "already sent" for it.
|
|
625
|
+
if (result.identityId !== this.state.identityId) this.userUpdates.forgetSent();
|
|
626
|
+
|
|
580
627
|
this.setState((current) => ({
|
|
581
628
|
booted: true,
|
|
582
629
|
loading: false,
|
|
@@ -800,7 +847,11 @@ export class WidgetStore {
|
|
|
800
847
|
const customAttributes = sanitizeAttributes(attributes);
|
|
801
848
|
if (Object.keys(customAttributes).length === 0) return;
|
|
802
849
|
|
|
803
|
-
|
|
850
|
+
if (!this.throttleUpdateUserEnabled) {
|
|
851
|
+
await this.client.identify({ customAttributes });
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
await this.userUpdates.update(customAttributes);
|
|
804
855
|
}
|
|
805
856
|
|
|
806
857
|
/**
|
|
@@ -812,6 +863,7 @@ export class WidgetStore {
|
|
|
812
863
|
this.loggedOut = true;
|
|
813
864
|
this.currentUser = null;
|
|
814
865
|
this.pendingConversationId = null;
|
|
866
|
+
this.userUpdates.reset();
|
|
815
867
|
this.realtime.close();
|
|
816
868
|
this.clearTypingTimer();
|
|
817
869
|
this.resetThreadTracking();
|
|
@@ -843,6 +895,8 @@ export class WidgetStore {
|
|
|
843
895
|
|
|
844
896
|
private async wipeSessionUnguarded(): Promise<void> {
|
|
845
897
|
this.realtime.close();
|
|
898
|
+
// Attributes waiting for the old visitor must never reach the next one.
|
|
899
|
+
this.userUpdates.reset();
|
|
846
900
|
this.clearTypingTimer();
|
|
847
901
|
this.resetThreadTracking();
|
|
848
902
|
this.appStateSubscription?.remove();
|
|
@@ -851,11 +905,15 @@ export class WidgetStore {
|
|
|
851
905
|
this.userKeyLoaded = true;
|
|
852
906
|
this.pendingAgentByConversation = {};
|
|
853
907
|
this.seenMessageIds = [];
|
|
908
|
+
this.lastMessages = {};
|
|
909
|
+
this.lastMessagesLoaded = false;
|
|
854
910
|
this.earlyStatuses.clear();
|
|
855
911
|
this.visitorTypingActive = false;
|
|
856
912
|
this.lastTypingPingAt = 0;
|
|
857
913
|
this.launchCardOffered = false;
|
|
858
914
|
this.resetPost();
|
|
915
|
+
clearLocalPreviews();
|
|
916
|
+
clearRetainedState();
|
|
859
917
|
this.setState({ ...USER_SCOPED_STATE });
|
|
860
918
|
|
|
861
919
|
const token = await this.client.getToken();
|
|
@@ -863,6 +921,7 @@ export class WidgetStore {
|
|
|
863
921
|
this.client.clearToken(),
|
|
864
922
|
SecureStorage.removeItem(USER_KEY),
|
|
865
923
|
Storage.removeItem(UNREAD_KEY),
|
|
924
|
+
Storage.removeItem(LAST_MESSAGE_KEY),
|
|
866
925
|
// A new anonymous visitor on this device gets a new tracking identity.
|
|
867
926
|
rotateDeviceId(),
|
|
868
927
|
]);
|
|
@@ -1284,6 +1343,7 @@ export class WidgetStore {
|
|
|
1284
1343
|
|
|
1285
1344
|
// Set inside the updater, read after: `setState` runs synchronously.
|
|
1286
1345
|
let countedAsUnread = false;
|
|
1346
|
+
const rowFields = rowFieldsOf(message);
|
|
1287
1347
|
|
|
1288
1348
|
this.setState((current) => {
|
|
1289
1349
|
const conversations = conversationId
|
|
@@ -1291,10 +1351,7 @@ export class WidgetStore {
|
|
|
1291
1351
|
conversation.id === conversationId
|
|
1292
1352
|
? {
|
|
1293
1353
|
...conversation,
|
|
1294
|
-
|
|
1295
|
-
message.messageType === 'message'
|
|
1296
|
-
? message.bodyPlain || conversation.snippet
|
|
1297
|
-
: conversation.snippet,
|
|
1354
|
+
...rowFields,
|
|
1298
1355
|
lastMessageAt: message.createdAt,
|
|
1299
1356
|
messageCount: conversation.messageCount + 1,
|
|
1300
1357
|
}
|
|
@@ -1336,12 +1393,14 @@ export class WidgetStore {
|
|
|
1336
1393
|
messageId,
|
|
1337
1394
|
senderName: message.senderName || conversation?.agentName || null,
|
|
1338
1395
|
senderAvatar: conversation?.agentAvatar ?? null,
|
|
1339
|
-
preview: (message
|
|
1396
|
+
preview: plainTextOf(message),
|
|
1340
1397
|
hasAttachments: (message.attachments?.length ?? 0) > 0,
|
|
1341
1398
|
},
|
|
1342
1399
|
};
|
|
1343
1400
|
});
|
|
1344
1401
|
|
|
1402
|
+
if (conversationId && rowFields) this.rememberLastMessage(conversationId, message.createdAt, rowFields, 'stream');
|
|
1403
|
+
|
|
1345
1404
|
// The visitor is reading it live — move the server cursor too, or the
|
|
1346
1405
|
// next list/init would badge a reply they already saw.
|
|
1347
1406
|
if ((onScreen || readInPost) && isTeamReply && conversationId) this.scheduleMarkRead(conversationId);
|
|
@@ -1438,10 +1497,116 @@ export class WidgetStore {
|
|
|
1438
1497
|
}
|
|
1439
1498
|
|
|
1440
1499
|
private hydrateConversation(conversation: Conversation): Conversation {
|
|
1441
|
-
return this.applyConversationAgent(
|
|
1500
|
+
return this.applyConversationAgent(
|
|
1501
|
+
this.withLastMessage({
|
|
1502
|
+
...conversation,
|
|
1503
|
+
agentAvatar: this.client.resolveUrl(conversation.agentAvatar),
|
|
1504
|
+
})
|
|
1505
|
+
);
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
/**
|
|
1509
|
+
* Workstation's list doesn't say who sent the newest message, and calls one
|
|
1510
|
+
* without words "2 attachments". While a row still describes the message
|
|
1511
|
+
* this device saw last, both are filled in from it. `snippet` keeps the
|
|
1512
|
+
* list's words: apps read it, and the lists draw "Photo" or "File" from
|
|
1513
|
+
* `lastMessageAttachment` instead.
|
|
1514
|
+
*/
|
|
1515
|
+
private withLastMessage(conversation: Conversation): Conversation {
|
|
1516
|
+
const known = this.lastMessages[conversation.id];
|
|
1517
|
+
if (!known || !describesSameMessage(conversation, known)) return conversation;
|
|
1518
|
+
// A list that says who sent it, or what it carried, is used as is.
|
|
1519
|
+
return {
|
|
1442
1520
|
...conversation,
|
|
1443
|
-
|
|
1521
|
+
lastMessageFromVisitor: conversation.lastMessageFromVisitor ?? known.fromVisitor,
|
|
1522
|
+
lastMessageAttachment:
|
|
1523
|
+
conversation.lastMessageAttachment !== undefined ? conversation.lastMessageAttachment : known.attachment,
|
|
1524
|
+
};
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
/**
|
|
1528
|
+
* A send or a streamed message is the newest by arrival, whatever the two
|
|
1529
|
+
* clocks say; a history page only replaces something older. A sent message
|
|
1530
|
+
* is timed by the phone, which may run ahead of Workstation, so instead a
|
|
1531
|
+
* page that has it replaces it: whatever is newest there came after it, and
|
|
1532
|
+
* Workstation's own time then stands in for the phone's.
|
|
1533
|
+
*/
|
|
1534
|
+
private rememberLastMessage(
|
|
1535
|
+
conversationId: string,
|
|
1536
|
+
at: string,
|
|
1537
|
+
fields: LastMessageFields,
|
|
1538
|
+
source: 'sent' | 'stream' | 'history',
|
|
1539
|
+
{ messageId, pageIds }: { messageId?: string; pageIds?: ReadonlySet<string> } = {}
|
|
1540
|
+
): void {
|
|
1541
|
+
const known = this.lastMessages[conversationId];
|
|
1542
|
+
if (source === 'history' && known) {
|
|
1543
|
+
const outdated =
|
|
1544
|
+
known.phoneClock && known.messageId
|
|
1545
|
+
? !pageIds?.has(known.messageId)
|
|
1546
|
+
: Date.parse(known.at) > Date.parse(at);
|
|
1547
|
+
if (outdated) return;
|
|
1548
|
+
}
|
|
1549
|
+
const hint: LastMessageHint = {
|
|
1550
|
+
at,
|
|
1551
|
+
fromVisitor: !!fields.lastMessageFromVisitor,
|
|
1552
|
+
attachment: fields.lastMessageAttachment ?? null,
|
|
1553
|
+
...(source === 'sent' ? { phoneClock: true, ...(messageId ? { messageId } : {}) } : {}),
|
|
1554
|
+
};
|
|
1555
|
+
this.lastMessages = Object.fromEntries(
|
|
1556
|
+
Object.entries({ ...this.lastMessages, [conversationId]: hint })
|
|
1557
|
+
.sort(([, a], [, b]) => Date.parse(b.at) - Date.parse(a.at))
|
|
1558
|
+
.slice(0, LAST_MESSAGE_LIMIT)
|
|
1559
|
+
);
|
|
1560
|
+
// Until the stored hints are read, saving would overwrite them with these alone.
|
|
1561
|
+
if (this.lastMessagesLoaded) void Storage.setItem(LAST_MESSAGE_KEY, JSON.stringify(this.lastMessages));
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
private async loadLastMessages(epoch: number): Promise<void> {
|
|
1565
|
+
if (this.lastMessagesLoaded) return;
|
|
1566
|
+
let stored: unknown = null;
|
|
1567
|
+
try {
|
|
1568
|
+
stored = JSON.parse((await Storage.getItem(LAST_MESSAGE_KEY)) ?? 'null');
|
|
1569
|
+
} catch {
|
|
1570
|
+
stored = null;
|
|
1571
|
+
}
|
|
1572
|
+
// Left unloaded: the init that replaces this one reads them again.
|
|
1573
|
+
if (this.isStale(epoch) || this.lastMessagesLoaded) return;
|
|
1574
|
+
this.lastMessagesLoaded = true;
|
|
1575
|
+
const learned = Object.keys(this.lastMessages).length > 0;
|
|
1576
|
+
if (stored && typeof stored === 'object') {
|
|
1577
|
+
for (const [conversationId, hint] of Object.entries(stored as Record<string, Partial<LastMessageHint>>)) {
|
|
1578
|
+
if (typeof hint?.at !== 'string' || typeof hint.fromVisitor !== 'boolean') continue;
|
|
1579
|
+
const known = this.lastMessages[conversationId];
|
|
1580
|
+
if (known && Date.parse(known.at) >= Date.parse(hint.at)) continue;
|
|
1581
|
+
this.lastMessages[conversationId] = {
|
|
1582
|
+
at: hint.at,
|
|
1583
|
+
fromVisitor: hint.fromVisitor,
|
|
1584
|
+
attachment: hint.attachment === 'photo' || hint.attachment === 'file' ? hint.attachment : null,
|
|
1585
|
+
...(hint.phoneClock === true ? { phoneClock: true } : {}),
|
|
1586
|
+
...(typeof hint.messageId === 'string' ? { messageId: hint.messageId } : {}),
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
// Hints learned while the stored ones were being read are saved with them.
|
|
1591
|
+
if (learned) void Storage.setItem(LAST_MESSAGE_KEY, JSON.stringify(this.lastMessages));
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
/** A thread's newest page shows who sent its newest message, so its row can say too. */
|
|
1595
|
+
private learnLastMessage(conversationId: string, page: Message[]): void {
|
|
1596
|
+
let newest: Message | null = null;
|
|
1597
|
+
for (const message of page) {
|
|
1598
|
+
if (!newest || Date.parse(message.createdAt) > Date.parse(newest.createdAt)) newest = message;
|
|
1599
|
+
}
|
|
1600
|
+
const fields = newest && rowFieldsOf(newest);
|
|
1601
|
+
if (!newest || !fields) return;
|
|
1602
|
+
this.rememberLastMessage(conversationId, newest.createdAt, fields, 'history', {
|
|
1603
|
+
pageIds: new Set(page.map((message) => message.id)),
|
|
1444
1604
|
});
|
|
1605
|
+
this.setState((current) => ({
|
|
1606
|
+
conversations: current.conversations.map((row) =>
|
|
1607
|
+
row.id === conversationId ? this.withLastMessage(row) : row
|
|
1608
|
+
),
|
|
1609
|
+
}));
|
|
1445
1610
|
}
|
|
1446
1611
|
|
|
1447
1612
|
/**
|
|
@@ -1452,21 +1617,30 @@ export class WidgetStore {
|
|
|
1452
1617
|
if (!Array.isArray(attachments) || attachments.length === 0) return null;
|
|
1453
1618
|
const hydrated = attachments
|
|
1454
1619
|
.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
|
|
1455
|
-
.map((attachment, index) =>
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1620
|
+
.map((attachment, index) => {
|
|
1621
|
+
const name =
|
|
1622
|
+
typeof attachment.name === 'string' && attachment.name ? attachment.name : 'Attachment';
|
|
1623
|
+
const declared = typeof attachment.contentType === 'string' ? attachment.contentType : '';
|
|
1624
|
+
return {
|
|
1625
|
+
...attachment,
|
|
1626
|
+
id:
|
|
1627
|
+
typeof attachment.id === 'string' || typeof attachment.id === 'number'
|
|
1628
|
+
? String(attachment.id)
|
|
1629
|
+
: `${messageId ?? 'attachment'}-${index}`,
|
|
1630
|
+
name,
|
|
1631
|
+
// Without a type (or with a generic one) the name says what it is,
|
|
1632
|
+
// so a photo still shows as a photo rather than a file row.
|
|
1633
|
+
contentType:
|
|
1634
|
+
declared && !/^application\/octet-stream$/i.test(declared)
|
|
1635
|
+
? declared
|
|
1636
|
+
: (guessContentType(name) ?? declared),
|
|
1637
|
+
size: typeof attachment.size === 'number' ? attachment.size : 0,
|
|
1638
|
+
// Empty (never the raw value) when the URL isn't http(s) — the preview
|
|
1639
|
+
// then renders the name only, with nothing to open.
|
|
1640
|
+
url:
|
|
1641
|
+
this.client.resolveUrl(typeof attachment.url === 'string' ? attachment.url : null) ?? '',
|
|
1642
|
+
};
|
|
1643
|
+
});
|
|
1470
1644
|
return hydrated.length > 0 ? hydrated : null;
|
|
1471
1645
|
}
|
|
1472
1646
|
|
|
@@ -1707,10 +1881,10 @@ export class WidgetStore {
|
|
|
1707
1881
|
const result = await this.client.listMessages(conversationId);
|
|
1708
1882
|
// Opening A then B quickly: A's page must never land under B.
|
|
1709
1883
|
if (this.isStale(epoch) || this.threadGeneration !== generation) return;
|
|
1884
|
+
const page = this.hydrateMessages(result.data);
|
|
1710
1885
|
this.setState((current) => {
|
|
1711
1886
|
// Live replies and optimistic sends may have arrived since this page
|
|
1712
1887
|
// was requested. Keep them, preferring the live copy for shared ids.
|
|
1713
|
-
const page = this.hydrateMessages(result.data);
|
|
1714
1888
|
const live = adoptOwnServerRows(current.messages, page);
|
|
1715
1889
|
const liveIds = new Set(live.map((message) => message.id));
|
|
1716
1890
|
const history = page.filter((message) => !liveIds.has(message.id));
|
|
@@ -1721,6 +1895,7 @@ export class WidgetStore {
|
|
|
1721
1895
|
messageCursor: result.meta.nextCursor,
|
|
1722
1896
|
};
|
|
1723
1897
|
});
|
|
1898
|
+
this.learnLastMessage(conversationId, page);
|
|
1724
1899
|
} catch {
|
|
1725
1900
|
if (!this.isStale(epoch) && this.threadGeneration === generation) {
|
|
1726
1901
|
this.setState({ messagesLoading: false });
|
|
@@ -1778,6 +1953,7 @@ export class WidgetStore {
|
|
|
1778
1953
|
if (this.state.activeConversationId !== conversationId) return;
|
|
1779
1954
|
|
|
1780
1955
|
const fresh = this.hydrateMessages(result.data);
|
|
1956
|
+
this.learnLastMessage(conversationId, fresh);
|
|
1781
1957
|
this.setState((current) => {
|
|
1782
1958
|
const own = adoptOwnServerRows(current.messages, fresh);
|
|
1783
1959
|
const known = new Set(own.map((message) => message.id));
|
|
@@ -1956,6 +2132,18 @@ export class WidgetStore {
|
|
|
1956
2132
|
return this.deliverMessage(tempId, trimmed, attachments, agentUiAction);
|
|
1957
2133
|
}
|
|
1958
2134
|
|
|
2135
|
+
/**
|
|
2136
|
+
* Puts a picked file in storage while the visitor is still writing, so
|
|
2137
|
+
* Send goes out at once. Resolves the token `sendMessage` names it by, and
|
|
2138
|
+
* when that token expires.
|
|
2139
|
+
*/
|
|
2140
|
+
uploadAttachment(
|
|
2141
|
+
file: { uri: string; name: string; contentType: string; size: number },
|
|
2142
|
+
signal?: AbortSignal
|
|
2143
|
+
): Promise<UploadedFile> {
|
|
2144
|
+
return this.client.uploadAttachment(file, signal);
|
|
2145
|
+
}
|
|
2146
|
+
|
|
1959
2147
|
/**
|
|
1960
2148
|
* Re-sends a text bubble that failed. It moves to the end of the thread,
|
|
1961
2149
|
* since it now goes out after anything sent meanwhile. Rejects like
|
|
@@ -2023,17 +2211,21 @@ export class WidgetStore {
|
|
|
2023
2211
|
|
|
2024
2212
|
let createdId: string | null = null;
|
|
2025
2213
|
try {
|
|
2026
|
-
// Local files go up first
|
|
2214
|
+
// Local files go up first, unless the composer already put them up; the
|
|
2215
|
+
// message names each by its upload token.
|
|
2027
2216
|
const sentAttachments = await Promise.all(
|
|
2028
|
-
attachments.map(async ({ name, url, fileUri, contentType, size }) =>
|
|
2029
|
-
|
|
2030
|
-
? {
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2217
|
+
attachments.map(async ({ name, url, fileUri, uploadToken, contentType, size }) =>
|
|
2218
|
+
uploadToken
|
|
2219
|
+
? { name, contentType, size, uploadToken }
|
|
2220
|
+
: fileUri
|
|
2221
|
+
? {
|
|
2222
|
+
name,
|
|
2223
|
+
contentType,
|
|
2224
|
+
size,
|
|
2225
|
+
uploadToken: (await this.client.uploadAttachment({ uri: fileUri, name, contentType, size }))
|
|
2226
|
+
.uploadToken,
|
|
2227
|
+
}
|
|
2228
|
+
: { name, url, contentType, size }
|
|
2037
2229
|
)
|
|
2038
2230
|
);
|
|
2039
2231
|
if (this.isStale(epoch)) return;
|
|
@@ -2052,6 +2244,10 @@ export class WidgetStore {
|
|
|
2052
2244
|
if (this.isStale(epoch)) return;
|
|
2053
2245
|
if (ownCreation) createdId = result.conversationId;
|
|
2054
2246
|
|
|
2247
|
+
// Workstation doesn't stream the visitor's own messages back, so the
|
|
2248
|
+
// list row learns about this one here.
|
|
2249
|
+
const sentAt = new Date().toISOString();
|
|
2250
|
+
const sentRow = rowFieldsFor(trimmed, attachments.map((attachment) => attachment.contentType), true);
|
|
2055
2251
|
this.setState((current) => {
|
|
2056
2252
|
// The stream or a catch-up fetch may have delivered this message
|
|
2057
2253
|
// already — keep one copy, never two bubbles with the same id.
|
|
@@ -2064,19 +2260,27 @@ export class WidgetStore {
|
|
|
2064
2260
|
: message
|
|
2065
2261
|
);
|
|
2066
2262
|
|
|
2067
|
-
if (!createdId)
|
|
2263
|
+
if (!createdId) {
|
|
2264
|
+
return {
|
|
2265
|
+
messages,
|
|
2266
|
+
conversations: current.conversations.map((row) =>
|
|
2267
|
+
row.id === result.conversationId
|
|
2268
|
+
? { ...row, ...sentRow, lastMessageAt: sentAt, messageCount: row.messageCount + (delivered ? 0 : 1) }
|
|
2269
|
+
: row
|
|
2270
|
+
),
|
|
2271
|
+
};
|
|
2272
|
+
}
|
|
2068
2273
|
|
|
2069
2274
|
const known = current.conversations.some((item) => item.id === result.conversationId);
|
|
2070
2275
|
const conversation: Conversation = {
|
|
2071
2276
|
id: result.conversationId,
|
|
2072
2277
|
subject: null,
|
|
2073
2278
|
status: 'open',
|
|
2074
|
-
snippet:
|
|
2075
|
-
|
|
2076
|
-
: `${attachments.length} attachment${attachments.length === 1 ? '' : 's'}`,
|
|
2279
|
+
snippet: null,
|
|
2280
|
+
...sentRow,
|
|
2077
2281
|
messageCount: 1,
|
|
2078
|
-
lastMessageAt:
|
|
2079
|
-
createdAt:
|
|
2282
|
+
lastMessageAt: sentAt,
|
|
2283
|
+
createdAt: sentAt,
|
|
2080
2284
|
agentName: null,
|
|
2081
2285
|
agentAvatar: null,
|
|
2082
2286
|
};
|
|
@@ -2092,6 +2296,7 @@ export class WidgetStore {
|
|
|
2092
2296
|
: {}),
|
|
2093
2297
|
};
|
|
2094
2298
|
});
|
|
2299
|
+
this.rememberLastMessage(result.conversationId, sentAt, sentRow, 'sent', { messageId: result.messageId });
|
|
2095
2300
|
|
|
2096
2301
|
// New files to fetch the stored copy of: a fresh round of re-reads.
|
|
2097
2302
|
const sent = this.state.messages.find((message) => message.id === result.messageId);
|
|
@@ -2547,6 +2752,7 @@ export class WidgetStore {
|
|
|
2547
2752
|
// ─── Teardown (tests / hot reload) ──────────────────────────────────────
|
|
2548
2753
|
|
|
2549
2754
|
destroy(): void {
|
|
2755
|
+
this.userUpdates.destroy();
|
|
2550
2756
|
this.clearPostRecheck();
|
|
2551
2757
|
this.clearPostThanks();
|
|
2552
2758
|
this.realtime.close();
|
|
@@ -2593,6 +2799,77 @@ function asDeletedPlaceholder(message: Message, deletedAt: string): Message {
|
|
|
2593
2799
|
return { ...message, body: null, bodyPlain: null, attachments: null, metadata: null, deletedAt };
|
|
2594
2800
|
}
|
|
2595
2801
|
|
|
2802
|
+
/** A message's words on one line; replies written in Workstation may only carry HTML. */
|
|
2803
|
+
function plainTextOf(message: Message): string {
|
|
2804
|
+
return (message.bodyPlain || stripHtml(message.body ?? '')).replace(/\s+/g, ' ').trim();
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
/**
|
|
2808
|
+
* The newest message of a thread as this device saw it. `at` is its
|
|
2809
|
+
* `createdAt`, which Workstation's list repeats as `lastMessageAt`; for a sent
|
|
2810
|
+
* message it's the phone's clock instead.
|
|
2811
|
+
*/
|
|
2812
|
+
interface LastMessageHint {
|
|
2813
|
+
at: string;
|
|
2814
|
+
fromVisitor: boolean;
|
|
2815
|
+
attachment: 'photo' | 'file' | null;
|
|
2816
|
+
phoneClock?: boolean;
|
|
2817
|
+
/** A sent message's id: a page from Workstation that has it can retime it. */
|
|
2818
|
+
messageId?: string;
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2821
|
+
/** `snippet` is absent when there's nothing to quote: the row keeps its words. */
|
|
2822
|
+
type LastMessageFields = Pick<Conversation, 'lastMessageAttachment' | 'lastMessageFromVisitor'> & {
|
|
2823
|
+
snippet?: string;
|
|
2824
|
+
};
|
|
2825
|
+
|
|
2826
|
+
/** The list row once `message` is the newest. Null keeps the row as it is: system rows and notes. */
|
|
2827
|
+
function rowFieldsOf(message: Message): LastMessageFields | null {
|
|
2828
|
+
if (message.messageType && message.messageType !== 'message') return null;
|
|
2829
|
+
return rowFieldsFor(
|
|
2830
|
+
plainTextOf(message),
|
|
2831
|
+
(message.attachments ?? []).map((attachment) => attachment.contentType),
|
|
2832
|
+
message.senderType === 'visitor' || message.direction === 'inbound',
|
|
2833
|
+
/<img\b/i.test(message.body ?? '')
|
|
2834
|
+
);
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2837
|
+
/**
|
|
2838
|
+
* Its words, or with none, whether it carried only photos or other files too.
|
|
2839
|
+
* A message with neither (a card) still says who sent it, so a teammate's
|
|
2840
|
+
* card never leaves "You:" on the visitor's earlier message.
|
|
2841
|
+
*/
|
|
2842
|
+
function rowFieldsFor(
|
|
2843
|
+
text: string,
|
|
2844
|
+
contentTypes: Array<string | null | undefined>,
|
|
2845
|
+
fromVisitor: boolean,
|
|
2846
|
+
inlinePhoto = false
|
|
2847
|
+
): LastMessageFields {
|
|
2848
|
+
if (text) return { snippet: text, lastMessageAttachment: null, lastMessageFromVisitor: fromVisitor };
|
|
2849
|
+
const count = contentTypes.length;
|
|
2850
|
+
if (count === 0 && !inlinePhoto) return { lastMessageAttachment: null, lastMessageFromVisitor: fromVisitor };
|
|
2851
|
+
return {
|
|
2852
|
+
// Workstation's list words it this way too, so apps reading `snippet` see the same after a reload.
|
|
2853
|
+
...(count > 0 ? { snippet: `${count} attachment${count === 1 ? '' : 's'}` } : {}),
|
|
2854
|
+
lastMessageAttachment: contentTypes.every((type) => isImageAttachment(type)) ? 'photo' : 'file',
|
|
2855
|
+
lastMessageFromVisitor: fromVisitor,
|
|
2856
|
+
};
|
|
2857
|
+
}
|
|
2858
|
+
|
|
2859
|
+
/**
|
|
2860
|
+
* The list stamps a conversation just after storing its message, so its time
|
|
2861
|
+
* trails the message's own by a few milliseconds, never more than `LIST_LAG_MS`.
|
|
2862
|
+
* A sent message is timed by the phone instead: a little drift either way
|
|
2863
|
+
* still matches, but only when the list rules out a team reply the visitor
|
|
2864
|
+
* hasn't read. Older Workstation deployments don't send `unreadCount`, so
|
|
2865
|
+
* there a sent message must match like any other.
|
|
2866
|
+
*/
|
|
2867
|
+
function describesSameMessage(conversation: Conversation, known: LastMessageHint): boolean {
|
|
2868
|
+
const lag = Date.parse(conversation.lastMessageAt ?? '') - Date.parse(known.at);
|
|
2869
|
+
if (lag >= 0 && lag <= LIST_LAG_MS) return true;
|
|
2870
|
+
return !!known.phoneClock && Math.abs(lag) <= SAME_MESSAGE_DRIFT_MS && conversation.unreadCount === 0;
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2596
2873
|
/** A deletion missed while the stream was down shows up on the next re-read. */
|
|
2597
2874
|
function adoptServerDeletions(messages: Message[], server: Message[]): Message[] {
|
|
2598
2875
|
const deleted = new Map(
|
|
@@ -2641,13 +2918,31 @@ function mergeAttachments(local: WidgetAttachment[], server: WidgetAttachment[])
|
|
|
2641
2918
|
if (local.length !== server.length) {
|
|
2642
2919
|
return server.every((attachment) => attachment.url) ? server : local;
|
|
2643
2920
|
}
|
|
2921
|
+
const owns = pairByName(local, server);
|
|
2644
2922
|
const merged = server.map((attachment, index) => {
|
|
2645
|
-
const own =
|
|
2646
|
-
|
|
2923
|
+
const own = owns[index]!;
|
|
2924
|
+
if (!isLocalAttachment(own) || !attachment.url) return own;
|
|
2925
|
+
rememberLocalPreview(attachment.url, own.url);
|
|
2926
|
+
return attachment;
|
|
2647
2927
|
});
|
|
2648
2928
|
return merged.every((attachment, index) => attachment === local[index]) ? local : merged;
|
|
2649
2929
|
}
|
|
2650
2930
|
|
|
2931
|
+
/**
|
|
2932
|
+
* The local attachment each server one replaces. By name first: Workstation
|
|
2933
|
+
* may store a message's files in another order than they were sent, and a
|
|
2934
|
+
* photo paired with the wrong file would show it for the rest of the session.
|
|
2935
|
+
* Any it renamed pair up in the order left.
|
|
2936
|
+
*/
|
|
2937
|
+
function pairByName(local: WidgetAttachment[], server: WidgetAttachment[]): WidgetAttachment[] {
|
|
2938
|
+
const unpaired = [...local];
|
|
2939
|
+
const named = server.map((attachment) => {
|
|
2940
|
+
const index = unpaired.findIndex((own) => own.name === attachment.name);
|
|
2941
|
+
return index < 0 ? null : unpaired.splice(index, 1)[0]!;
|
|
2942
|
+
});
|
|
2943
|
+
return named.map((own) => own ?? unpaired.shift()!);
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2651
2946
|
/**
|
|
2652
2947
|
* Newer Workstation deployments echo `clientMessageId` on the visitor's own
|
|
2653
2948
|
* rows. Such a row replaces the pending or failed bubble it came from instead
|
package/src/storage.ts
CHANGED
|
@@ -20,6 +20,8 @@ export const TOKEN_KEY = 'repzo_widget_token';
|
|
|
20
20
|
export const USER_KEY = 'repzo_widget_user_key';
|
|
21
21
|
export const DEVICE_ID_KEY = 'repzo_widget_device_id';
|
|
22
22
|
export const UNREAD_KEY = 'repzo_widget_unread';
|
|
23
|
+
/** Who sent each conversation's newest message; Workstation's list doesn't say. */
|
|
24
|
+
export const LAST_MESSAGE_KEY = 'repzo_widget_last_message';
|
|
23
25
|
/** Last config received — repaints the real theme instantly on relaunch. */
|
|
24
26
|
/**
|
|
25
27
|
* v2 wraps the config with the channel/baseUrl/platform it came from. v1 held a
|