@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
|
@@ -16,14 +16,18 @@ import { WidgetRealtime } from "../api/realtime.js";
|
|
|
16
16
|
import { getDeviceId, rotateDeviceId } from "../device-id.js";
|
|
17
17
|
import { EventEmitter, RepzoWidgetEvents } from "../events.js";
|
|
18
18
|
import { PushManager } from "../push/push-notifications.js";
|
|
19
|
-
import { CONFIG_KEY, LEGACY_CONFIG_KEY, SecureStorage, Storage, UNREAD_KEY, USER_KEY } from "../storage.js";
|
|
20
|
-
import {
|
|
19
|
+
import { CONFIG_KEY, LAST_MESSAGE_KEY, LEGACY_CONFIG_KEY, SecureStorage, Storage, UNREAD_KEY, USER_KEY } from "../storage.js";
|
|
20
|
+
import { clearLocalPreviews, rememberLocalPreview } from "../attachments/local-previews.js";
|
|
21
|
+
import { guessContentType, isImageAttachment, setAttachmentOptions } from "../attachments/pickers.js";
|
|
21
22
|
import { DEFAULT_BASE_URL } from "../types.js";
|
|
23
|
+
import { clearRetainedState } from "../ui/hooks/use-retained-state.js";
|
|
22
24
|
import { canRetryMessage, isLocalMessage } from "../ui/utils/conversations.js";
|
|
25
|
+
import { stripHtml } from "../ui/utils/html.js";
|
|
23
26
|
import { setOpenUrlHandler } from "../ui/utils/safe-url.js";
|
|
27
|
+
import { UserUpdateQueue } from "./user-update-queue.js";
|
|
24
28
|
|
|
25
29
|
/** Sent on every init; must match `package.json` (tests/version.test.js checks). */
|
|
26
|
-
const SDK_VERSION = '@repzo/widget-react-native@1.
|
|
30
|
+
const SDK_VERSION = '@repzo/widget-react-native@1.5.0';
|
|
27
31
|
|
|
28
32
|
/** The platform this session reports — mobile sessions get the Mobile SDKs overrides. */
|
|
29
33
|
function sessionPlatform() {
|
|
@@ -38,6 +42,12 @@ const TYPING_SETTLE_MS = 3_000;
|
|
|
38
42
|
const TYPING_PING_INTERVAL_MS = 4_000;
|
|
39
43
|
/** Guards against the SSE stream replaying messages after a reconnect. */
|
|
40
44
|
const SEEN_MESSAGE_LIMIT = 100;
|
|
45
|
+
/** Threads whose newest message is remembered across launches. */
|
|
46
|
+
const LAST_MESSAGE_LIMIT = 50;
|
|
47
|
+
/** How far the phone's clock may be from Workstation's for a sent message to still match the list. */
|
|
48
|
+
const SAME_MESSAGE_DRIFT_MS = 120_000;
|
|
49
|
+
/** How far the list's `lastMessageAt` may trail the message's own `createdAt` (7 ms seen live). */
|
|
50
|
+
const LIST_LAG_MS = 1_000;
|
|
41
51
|
/** Logout's server calls are best-effort — a dead network must not block sign-out. */
|
|
42
52
|
const LOGOUT_REQUEST_TIMEOUT_MS = 5_000;
|
|
43
53
|
/** A server that 401s every fresh stream must not spin init (10/min limit). */
|
|
@@ -115,6 +125,11 @@ const USER_SCOPED_STATE = {
|
|
|
115
125
|
};
|
|
116
126
|
export class WidgetStore {
|
|
117
127
|
client = new WidgetApiClient();
|
|
128
|
+
|
|
129
|
+
/** Paces `updateUser()` — see `boot({ throttleUpdateUser })`. */
|
|
130
|
+
userUpdates = new UserUpdateQueue(customAttributes => this.client.identify({
|
|
131
|
+
customAttributes
|
|
132
|
+
}));
|
|
118
133
|
events = new EventEmitter();
|
|
119
134
|
push = new PushManager(this.client, conversationId => {
|
|
120
135
|
void this.openConversation(conversationId);
|
|
@@ -131,6 +146,12 @@ export class WidgetStore {
|
|
|
131
146
|
|
|
132
147
|
/** `boot({ posts })` — Send posts over the app. */
|
|
133
148
|
postsEnabled = true;
|
|
149
|
+
/** `boot({ photoAlbums })` — a message's photos share one album in the thread. */
|
|
150
|
+
photoAlbumsEnabled = true;
|
|
151
|
+
/** `boot({ youPrefix })` — "You:" before the visitor's own newest message in the lists. */
|
|
152
|
+
youPrefixEnabled = true;
|
|
153
|
+
/** `boot({ throttleUpdateUser })` — skip unchanged `updateUser()` values and pace the rest. */
|
|
154
|
+
throttleUpdateUserEnabled = true;
|
|
134
155
|
|
|
135
156
|
/** Posts whose view was reported this session (the server also dedupes per day). */
|
|
136
157
|
viewedPostIds = new Set();
|
|
@@ -185,6 +206,10 @@ export class WidgetStore {
|
|
|
185
206
|
lastTypingPingAt = 0;
|
|
186
207
|
visitorTypingActive = false;
|
|
187
208
|
seenMessageIds = [];
|
|
209
|
+
|
|
210
|
+
/** Who sent each thread's newest message and what it carried; see `withLastMessage`. */
|
|
211
|
+
lastMessages = {};
|
|
212
|
+
lastMessagesLoaded = false;
|
|
188
213
|
/** Statuses (e.g. "read") that arrived for an id before a send response revealed it. */
|
|
189
214
|
earlyStatuses = new Map();
|
|
190
215
|
/** The agent UI action behind a failed bubble, so Retry runs it again too. */
|
|
@@ -264,6 +289,8 @@ export class WidgetStore {
|
|
|
264
289
|
|
|
265
290
|
/** Composer identity: explicit opens reset it; a draft receiving its server ID does not. */
|
|
266
291
|
getThreadGeneration = () => this.threadGeneration;
|
|
292
|
+
showsPhotoAlbums = () => this.photoAlbumsEnabled;
|
|
293
|
+
showsYouPrefix = () => this.youPrefixEnabled;
|
|
267
294
|
setState(patch) {
|
|
268
295
|
const previous = this.state;
|
|
269
296
|
const resolved = typeof patch === 'function' ? patch(previous) : patch;
|
|
@@ -335,6 +362,11 @@ export class WidgetStore {
|
|
|
335
362
|
if (options.inAppNotifications !== undefined) {
|
|
336
363
|
this.inAppNotificationsEnabled = options.inAppNotifications !== false;
|
|
337
364
|
}
|
|
365
|
+
if (options.photoAlbums !== undefined) this.photoAlbumsEnabled = options.photoAlbums !== false;
|
|
366
|
+
if (options.youPrefix !== undefined) this.youPrefixEnabled = options.youPrefix !== false;
|
|
367
|
+
if (options.throttleUpdateUser !== undefined) {
|
|
368
|
+
this.throttleUpdateUserEnabled = options.throttleUpdateUser !== false;
|
|
369
|
+
}
|
|
338
370
|
if (options.posts !== undefined) {
|
|
339
371
|
const wasEnabled = this.postsEnabled;
|
|
340
372
|
this.postsEnabled = options.posts !== false;
|
|
@@ -489,13 +521,13 @@ export class WidgetStore {
|
|
|
489
521
|
// queued op must never see one without the other.
|
|
490
522
|
await Promise.all([this.client.setToken(result.token), userKey ? SecureStorage.setItem(USER_KEY, userKey) : SecureStorage.removeItem(USER_KEY)]);
|
|
491
523
|
if (this.isStale(epoch)) return;
|
|
492
|
-
const conversations = result.conversations.map(conversation => this.hydrateConversation(conversation));
|
|
493
524
|
|
|
494
525
|
// Server counts win (they include replies pushed while the app was
|
|
495
526
|
// killed); the locally persisted map only covers older Workstation
|
|
496
527
|
// deployments that don't send unreadCount yet.
|
|
497
|
-
const storedUnread = await this.loadUnread();
|
|
528
|
+
const [, storedUnread] = await Promise.all([this.loadLastMessages(epoch), this.loadUnread()]);
|
|
498
529
|
if (this.isStale(epoch)) return;
|
|
530
|
+
const conversations = result.conversations.map(conversation => this.hydrateConversation(conversation));
|
|
499
531
|
const unreadByConversation = {};
|
|
500
532
|
for (const conversation of conversations) {
|
|
501
533
|
const count = typeof conversation.unreadCount === 'number' ? conversation.unreadCount : storedUnread[conversation.id] ?? 0;
|
|
@@ -505,6 +537,10 @@ export class WidgetStore {
|
|
|
505
537
|
const onScreenId = this.state.activeConversationId;
|
|
506
538
|
if (onScreenId && this.isThreadOnScreen(onScreenId)) delete unreadByConversation[onScreenId];
|
|
507
539
|
this.persistUnread(unreadByConversation);
|
|
540
|
+
|
|
541
|
+
// A new identity (anonymous → signed in) may hold other attributes, so
|
|
542
|
+
// nothing is skipped as "already sent" for it.
|
|
543
|
+
if (result.identityId !== this.state.identityId) this.userUpdates.forgetSent();
|
|
508
544
|
this.setState(current => ({
|
|
509
545
|
booted: true,
|
|
510
546
|
loading: false,
|
|
@@ -733,9 +769,13 @@ export class WidgetStore {
|
|
|
733
769
|
if (!this.state.booted) await this.bootPromise;
|
|
734
770
|
const customAttributes = sanitizeAttributes(attributes);
|
|
735
771
|
if (Object.keys(customAttributes).length === 0) return;
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
772
|
+
if (!this.throttleUpdateUserEnabled) {
|
|
773
|
+
await this.client.identify({
|
|
774
|
+
customAttributes
|
|
775
|
+
});
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
await this.userUpdates.update(customAttributes);
|
|
739
779
|
}
|
|
740
780
|
|
|
741
781
|
/**
|
|
@@ -747,6 +787,7 @@ export class WidgetStore {
|
|
|
747
787
|
this.loggedOut = true;
|
|
748
788
|
this.currentUser = null;
|
|
749
789
|
this.pendingConversationId = null;
|
|
790
|
+
this.userUpdates.reset();
|
|
750
791
|
this.realtime.close();
|
|
751
792
|
this.clearTypingTimer();
|
|
752
793
|
this.resetThreadTracking();
|
|
@@ -778,6 +819,8 @@ export class WidgetStore {
|
|
|
778
819
|
}
|
|
779
820
|
async wipeSessionUnguarded() {
|
|
780
821
|
this.realtime.close();
|
|
822
|
+
// Attributes waiting for the old visitor must never reach the next one.
|
|
823
|
+
this.userUpdates.reset();
|
|
781
824
|
this.clearTypingTimer();
|
|
782
825
|
this.resetThreadTracking();
|
|
783
826
|
this.appStateSubscription?.remove();
|
|
@@ -786,16 +829,20 @@ export class WidgetStore {
|
|
|
786
829
|
this.userKeyLoaded = true;
|
|
787
830
|
this.pendingAgentByConversation = {};
|
|
788
831
|
this.seenMessageIds = [];
|
|
832
|
+
this.lastMessages = {};
|
|
833
|
+
this.lastMessagesLoaded = false;
|
|
789
834
|
this.earlyStatuses.clear();
|
|
790
835
|
this.visitorTypingActive = false;
|
|
791
836
|
this.lastTypingPingAt = 0;
|
|
792
837
|
this.launchCardOffered = false;
|
|
793
838
|
this.resetPost();
|
|
839
|
+
clearLocalPreviews();
|
|
840
|
+
clearRetainedState();
|
|
794
841
|
this.setState({
|
|
795
842
|
...USER_SCOPED_STATE
|
|
796
843
|
});
|
|
797
844
|
const token = await this.client.getToken();
|
|
798
|
-
await Promise.all([this.client.clearToken(), SecureStorage.removeItem(USER_KEY), Storage.removeItem(UNREAD_KEY),
|
|
845
|
+
await Promise.all([this.client.clearToken(), SecureStorage.removeItem(USER_KEY), Storage.removeItem(UNREAD_KEY), Storage.removeItem(LAST_MESSAGE_KEY),
|
|
799
846
|
// A new anonymous visitor on this device gets a new tracking identity.
|
|
800
847
|
rotateDeviceId()]);
|
|
801
848
|
|
|
@@ -1175,10 +1222,11 @@ export class WidgetStore {
|
|
|
1175
1222
|
|
|
1176
1223
|
// Set inside the updater, read after: `setState` runs synchronously.
|
|
1177
1224
|
let countedAsUnread = false;
|
|
1225
|
+
const rowFields = rowFieldsOf(message);
|
|
1178
1226
|
this.setState(current => {
|
|
1179
1227
|
const conversations = conversationId ? current.conversations.map(conversation => conversation.id === conversationId ? {
|
|
1180
1228
|
...conversation,
|
|
1181
|
-
|
|
1229
|
+
...rowFields,
|
|
1182
1230
|
lastMessageAt: message.createdAt,
|
|
1183
1231
|
messageCount: conversation.messageCount + 1
|
|
1184
1232
|
} : conversation) : current.conversations;
|
|
@@ -1216,11 +1264,12 @@ export class WidgetStore {
|
|
|
1216
1264
|
messageId,
|
|
1217
1265
|
senderName: message.senderName || conversation?.agentName || null,
|
|
1218
1266
|
senderAvatar: conversation?.agentAvatar ?? null,
|
|
1219
|
-
preview: (message
|
|
1267
|
+
preview: plainTextOf(message),
|
|
1220
1268
|
hasAttachments: (message.attachments?.length ?? 0) > 0
|
|
1221
1269
|
}
|
|
1222
1270
|
};
|
|
1223
1271
|
});
|
|
1272
|
+
if (conversationId && rowFields) this.rememberLastMessage(conversationId, message.createdAt, rowFields, 'stream');
|
|
1224
1273
|
|
|
1225
1274
|
// The visitor is reading it live — move the server cursor too, or the
|
|
1226
1275
|
// next list/init would badge a reply they already saw.
|
|
@@ -1318,10 +1367,112 @@ export class WidgetStore {
|
|
|
1318
1367
|
};
|
|
1319
1368
|
}
|
|
1320
1369
|
hydrateConversation(conversation) {
|
|
1321
|
-
return this.applyConversationAgent({
|
|
1370
|
+
return this.applyConversationAgent(this.withLastMessage({
|
|
1322
1371
|
...conversation,
|
|
1323
1372
|
agentAvatar: this.client.resolveUrl(conversation.agentAvatar)
|
|
1373
|
+
}));
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/**
|
|
1377
|
+
* Workstation's list doesn't say who sent the newest message, and calls one
|
|
1378
|
+
* without words "2 attachments". While a row still describes the message
|
|
1379
|
+
* this device saw last, both are filled in from it. `snippet` keeps the
|
|
1380
|
+
* list's words: apps read it, and the lists draw "Photo" or "File" from
|
|
1381
|
+
* `lastMessageAttachment` instead.
|
|
1382
|
+
*/
|
|
1383
|
+
withLastMessage(conversation) {
|
|
1384
|
+
const known = this.lastMessages[conversation.id];
|
|
1385
|
+
if (!known || !describesSameMessage(conversation, known)) return conversation;
|
|
1386
|
+
// A list that says who sent it, or what it carried, is used as is.
|
|
1387
|
+
return {
|
|
1388
|
+
...conversation,
|
|
1389
|
+
lastMessageFromVisitor: conversation.lastMessageFromVisitor ?? known.fromVisitor,
|
|
1390
|
+
lastMessageAttachment: conversation.lastMessageAttachment !== undefined ? conversation.lastMessageAttachment : known.attachment
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
/**
|
|
1395
|
+
* A send or a streamed message is the newest by arrival, whatever the two
|
|
1396
|
+
* clocks say; a history page only replaces something older. A sent message
|
|
1397
|
+
* is timed by the phone, which may run ahead of Workstation, so instead a
|
|
1398
|
+
* page that has it replaces it: whatever is newest there came after it, and
|
|
1399
|
+
* Workstation's own time then stands in for the phone's.
|
|
1400
|
+
*/
|
|
1401
|
+
rememberLastMessage(conversationId, at, fields, source, {
|
|
1402
|
+
messageId,
|
|
1403
|
+
pageIds
|
|
1404
|
+
} = {}) {
|
|
1405
|
+
const known = this.lastMessages[conversationId];
|
|
1406
|
+
if (source === 'history' && known) {
|
|
1407
|
+
const outdated = known.phoneClock && known.messageId ? !pageIds?.has(known.messageId) : Date.parse(known.at) > Date.parse(at);
|
|
1408
|
+
if (outdated) return;
|
|
1409
|
+
}
|
|
1410
|
+
const hint = {
|
|
1411
|
+
at,
|
|
1412
|
+
fromVisitor: !!fields.lastMessageFromVisitor,
|
|
1413
|
+
attachment: fields.lastMessageAttachment ?? null,
|
|
1414
|
+
...(source === 'sent' ? {
|
|
1415
|
+
phoneClock: true,
|
|
1416
|
+
...(messageId ? {
|
|
1417
|
+
messageId
|
|
1418
|
+
} : {})
|
|
1419
|
+
} : {})
|
|
1420
|
+
};
|
|
1421
|
+
this.lastMessages = Object.fromEntries(Object.entries({
|
|
1422
|
+
...this.lastMessages,
|
|
1423
|
+
[conversationId]: hint
|
|
1424
|
+
}).sort(([, a], [, b]) => Date.parse(b.at) - Date.parse(a.at)).slice(0, LAST_MESSAGE_LIMIT));
|
|
1425
|
+
// Until the stored hints are read, saving would overwrite them with these alone.
|
|
1426
|
+
if (this.lastMessagesLoaded) void Storage.setItem(LAST_MESSAGE_KEY, JSON.stringify(this.lastMessages));
|
|
1427
|
+
}
|
|
1428
|
+
async loadLastMessages(epoch) {
|
|
1429
|
+
if (this.lastMessagesLoaded) return;
|
|
1430
|
+
let stored = null;
|
|
1431
|
+
try {
|
|
1432
|
+
stored = JSON.parse((await Storage.getItem(LAST_MESSAGE_KEY)) ?? 'null');
|
|
1433
|
+
} catch {
|
|
1434
|
+
stored = null;
|
|
1435
|
+
}
|
|
1436
|
+
// Left unloaded: the init that replaces this one reads them again.
|
|
1437
|
+
if (this.isStale(epoch) || this.lastMessagesLoaded) return;
|
|
1438
|
+
this.lastMessagesLoaded = true;
|
|
1439
|
+
const learned = Object.keys(this.lastMessages).length > 0;
|
|
1440
|
+
if (stored && typeof stored === 'object') {
|
|
1441
|
+
for (const [conversationId, hint] of Object.entries(stored)) {
|
|
1442
|
+
if (typeof hint?.at !== 'string' || typeof hint.fromVisitor !== 'boolean') continue;
|
|
1443
|
+
const known = this.lastMessages[conversationId];
|
|
1444
|
+
if (known && Date.parse(known.at) >= Date.parse(hint.at)) continue;
|
|
1445
|
+
this.lastMessages[conversationId] = {
|
|
1446
|
+
at: hint.at,
|
|
1447
|
+
fromVisitor: hint.fromVisitor,
|
|
1448
|
+
attachment: hint.attachment === 'photo' || hint.attachment === 'file' ? hint.attachment : null,
|
|
1449
|
+
...(hint.phoneClock === true ? {
|
|
1450
|
+
phoneClock: true
|
|
1451
|
+
} : {}),
|
|
1452
|
+
...(typeof hint.messageId === 'string' ? {
|
|
1453
|
+
messageId: hint.messageId
|
|
1454
|
+
} : {})
|
|
1455
|
+
};
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
// Hints learned while the stored ones were being read are saved with them.
|
|
1459
|
+
if (learned) void Storage.setItem(LAST_MESSAGE_KEY, JSON.stringify(this.lastMessages));
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
/** A thread's newest page shows who sent its newest message, so its row can say too. */
|
|
1463
|
+
learnLastMessage(conversationId, page) {
|
|
1464
|
+
let newest = null;
|
|
1465
|
+
for (const message of page) {
|
|
1466
|
+
if (!newest || Date.parse(message.createdAt) > Date.parse(newest.createdAt)) newest = message;
|
|
1467
|
+
}
|
|
1468
|
+
const fields = newest && rowFieldsOf(newest);
|
|
1469
|
+
if (!newest || !fields) return;
|
|
1470
|
+
this.rememberLastMessage(conversationId, newest.createdAt, fields, 'history', {
|
|
1471
|
+
pageIds: new Set(page.map(message => message.id))
|
|
1324
1472
|
});
|
|
1473
|
+
this.setState(current => ({
|
|
1474
|
+
conversations: current.conversations.map(row => row.id === conversationId ? this.withLastMessage(row) : row)
|
|
1475
|
+
}));
|
|
1325
1476
|
}
|
|
1326
1477
|
|
|
1327
1478
|
/**
|
|
@@ -1330,16 +1481,22 @@ export class WidgetStore {
|
|
|
1330
1481
|
*/
|
|
1331
1482
|
hydrateAttachments(attachments, messageId) {
|
|
1332
1483
|
if (!Array.isArray(attachments) || attachments.length === 0) return null;
|
|
1333
|
-
const hydrated = attachments.filter(item => !!item && typeof item === 'object').map((attachment, index) =>
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1484
|
+
const hydrated = attachments.filter(item => !!item && typeof item === 'object').map((attachment, index) => {
|
|
1485
|
+
const name = typeof attachment.name === 'string' && attachment.name ? attachment.name : 'Attachment';
|
|
1486
|
+
const declared = typeof attachment.contentType === 'string' ? attachment.contentType : '';
|
|
1487
|
+
return {
|
|
1488
|
+
...attachment,
|
|
1489
|
+
id: typeof attachment.id === 'string' || typeof attachment.id === 'number' ? String(attachment.id) : `${messageId ?? 'attachment'}-${index}`,
|
|
1490
|
+
name,
|
|
1491
|
+
// Without a type (or with a generic one) the name says what it is,
|
|
1492
|
+
// so a photo still shows as a photo rather than a file row.
|
|
1493
|
+
contentType: declared && !/^application\/octet-stream$/i.test(declared) ? declared : guessContentType(name) ?? declared,
|
|
1494
|
+
size: typeof attachment.size === 'number' ? attachment.size : 0,
|
|
1495
|
+
// Empty (never the raw value) when the URL isn't http(s) — the preview
|
|
1496
|
+
// then renders the name only, with nothing to open.
|
|
1497
|
+
url: this.client.resolveUrl(typeof attachment.url === 'string' ? attachment.url : null) ?? ''
|
|
1498
|
+
};
|
|
1499
|
+
});
|
|
1343
1500
|
return hydrated.length > 0 ? hydrated : null;
|
|
1344
1501
|
}
|
|
1345
1502
|
clearTypingTimer() {
|
|
@@ -1595,10 +1752,10 @@ export class WidgetStore {
|
|
|
1595
1752
|
const result = await this.client.listMessages(conversationId);
|
|
1596
1753
|
// Opening A then B quickly: A's page must never land under B.
|
|
1597
1754
|
if (this.isStale(epoch) || this.threadGeneration !== generation) return;
|
|
1755
|
+
const page = this.hydrateMessages(result.data);
|
|
1598
1756
|
this.setState(current => {
|
|
1599
1757
|
// Live replies and optimistic sends may have arrived since this page
|
|
1600
1758
|
// was requested. Keep them, preferring the live copy for shared ids.
|
|
1601
|
-
const page = this.hydrateMessages(result.data);
|
|
1602
1759
|
const live = adoptOwnServerRows(current.messages, page);
|
|
1603
1760
|
const liveIds = new Set(live.map(message => message.id));
|
|
1604
1761
|
const history = page.filter(message => !liveIds.has(message.id));
|
|
@@ -1609,6 +1766,7 @@ export class WidgetStore {
|
|
|
1609
1766
|
messageCursor: result.meta.nextCursor
|
|
1610
1767
|
};
|
|
1611
1768
|
});
|
|
1769
|
+
this.learnLastMessage(conversationId, page);
|
|
1612
1770
|
} catch {
|
|
1613
1771
|
if (!this.isStale(epoch) && this.threadGeneration === generation) {
|
|
1614
1772
|
this.setState({
|
|
@@ -1670,6 +1828,7 @@ export class WidgetStore {
|
|
|
1670
1828
|
if (this.isStale(epoch) || this.threadGeneration !== generation) return;
|
|
1671
1829
|
if (this.state.activeConversationId !== conversationId) return;
|
|
1672
1830
|
const fresh = this.hydrateMessages(result.data);
|
|
1831
|
+
this.learnLastMessage(conversationId, fresh);
|
|
1673
1832
|
this.setState(current => {
|
|
1674
1833
|
const own = adoptOwnServerRows(current.messages, fresh);
|
|
1675
1834
|
const known = new Set(own.map(message => message.id));
|
|
@@ -1836,6 +1995,15 @@ export class WidgetStore {
|
|
|
1836
1995
|
return this.deliverMessage(tempId, trimmed, attachments, agentUiAction);
|
|
1837
1996
|
}
|
|
1838
1997
|
|
|
1998
|
+
/**
|
|
1999
|
+
* Puts a picked file in storage while the visitor is still writing, so
|
|
2000
|
+
* Send goes out at once. Resolves the token `sendMessage` names it by, and
|
|
2001
|
+
* when that token expires.
|
|
2002
|
+
*/
|
|
2003
|
+
uploadAttachment(file, signal) {
|
|
2004
|
+
return this.client.uploadAttachment(file, signal);
|
|
2005
|
+
}
|
|
2006
|
+
|
|
1839
2007
|
/**
|
|
1840
2008
|
* Re-sends a text bubble that failed. It moves to the end of the thread,
|
|
1841
2009
|
* since it now goes out after anything sent meanwhile. Rejects like
|
|
@@ -1897,23 +2065,30 @@ export class WidgetStore {
|
|
|
1897
2065
|
}
|
|
1898
2066
|
let createdId = null;
|
|
1899
2067
|
try {
|
|
1900
|
-
// Local files go up first
|
|
2068
|
+
// Local files go up first, unless the composer already put them up; the
|
|
2069
|
+
// message names each by its upload token.
|
|
1901
2070
|
const sentAttachments = await Promise.all(attachments.map(async ({
|
|
1902
2071
|
name,
|
|
1903
2072
|
url,
|
|
1904
2073
|
fileUri,
|
|
2074
|
+
uploadToken,
|
|
1905
2075
|
contentType,
|
|
1906
2076
|
size
|
|
1907
|
-
}) =>
|
|
2077
|
+
}) => uploadToken ? {
|
|
1908
2078
|
name,
|
|
1909
2079
|
contentType,
|
|
1910
2080
|
size,
|
|
1911
|
-
uploadToken
|
|
2081
|
+
uploadToken
|
|
2082
|
+
} : fileUri ? {
|
|
2083
|
+
name,
|
|
2084
|
+
contentType,
|
|
2085
|
+
size,
|
|
2086
|
+
uploadToken: (await this.client.uploadAttachment({
|
|
1912
2087
|
uri: fileUri,
|
|
1913
2088
|
name,
|
|
1914
2089
|
contentType,
|
|
1915
2090
|
size
|
|
1916
|
-
})
|
|
2091
|
+
})).uploadToken
|
|
1917
2092
|
} : {
|
|
1918
2093
|
name,
|
|
1919
2094
|
url,
|
|
@@ -1935,6 +2110,11 @@ export class WidgetStore {
|
|
|
1935
2110
|
});
|
|
1936
2111
|
if (this.isStale(epoch)) return;
|
|
1937
2112
|
if (ownCreation) createdId = result.conversationId;
|
|
2113
|
+
|
|
2114
|
+
// Workstation doesn't stream the visitor's own messages back, so the
|
|
2115
|
+
// list row learns about this one here.
|
|
2116
|
+
const sentAt = new Date().toISOString();
|
|
2117
|
+
const sentRow = rowFieldsFor(trimmed, attachments.map(attachment => attachment.contentType), true);
|
|
1938
2118
|
this.setState(current => {
|
|
1939
2119
|
// The stream or a catch-up fetch may have delivered this message
|
|
1940
2120
|
// already — keep one copy, never two bubbles with the same id.
|
|
@@ -1944,18 +2124,27 @@ export class WidgetStore {
|
|
|
1944
2124
|
id: result.messageId,
|
|
1945
2125
|
status: this.earlyStatuses.get(result.messageId) ?? 'sent'
|
|
1946
2126
|
} : message);
|
|
1947
|
-
if (!createdId)
|
|
1948
|
-
|
|
1949
|
-
|
|
2127
|
+
if (!createdId) {
|
|
2128
|
+
return {
|
|
2129
|
+
messages,
|
|
2130
|
+
conversations: current.conversations.map(row => row.id === result.conversationId ? {
|
|
2131
|
+
...row,
|
|
2132
|
+
...sentRow,
|
|
2133
|
+
lastMessageAt: sentAt,
|
|
2134
|
+
messageCount: row.messageCount + (delivered ? 0 : 1)
|
|
2135
|
+
} : row)
|
|
2136
|
+
};
|
|
2137
|
+
}
|
|
1950
2138
|
const known = current.conversations.some(item => item.id === result.conversationId);
|
|
1951
2139
|
const conversation = {
|
|
1952
2140
|
id: result.conversationId,
|
|
1953
2141
|
subject: null,
|
|
1954
2142
|
status: 'open',
|
|
1955
|
-
snippet:
|
|
2143
|
+
snippet: null,
|
|
2144
|
+
...sentRow,
|
|
1956
2145
|
messageCount: 1,
|
|
1957
|
-
lastMessageAt:
|
|
1958
|
-
createdAt:
|
|
2146
|
+
lastMessageAt: sentAt,
|
|
2147
|
+
createdAt: sentAt,
|
|
1959
2148
|
agentName: null,
|
|
1960
2149
|
agentAvatar: null
|
|
1961
2150
|
};
|
|
@@ -1968,6 +2157,9 @@ export class WidgetStore {
|
|
|
1968
2157
|
} : {})
|
|
1969
2158
|
};
|
|
1970
2159
|
});
|
|
2160
|
+
this.rememberLastMessage(result.conversationId, sentAt, sentRow, 'sent', {
|
|
2161
|
+
messageId: result.messageId
|
|
2162
|
+
});
|
|
1971
2163
|
|
|
1972
2164
|
// New files to fetch the stored copy of: a fresh round of re-reads.
|
|
1973
2165
|
const sent = this.state.messages.find(message => message.id === result.messageId);
|
|
@@ -2439,6 +2631,7 @@ export class WidgetStore {
|
|
|
2439
2631
|
// ─── Teardown (tests / hot reload) ──────────────────────────────────────
|
|
2440
2632
|
|
|
2441
2633
|
destroy() {
|
|
2634
|
+
this.userUpdates.destroy();
|
|
2442
2635
|
this.clearPostRecheck();
|
|
2443
2636
|
this.clearPostThanks();
|
|
2444
2637
|
this.realtime.close();
|
|
@@ -2490,6 +2683,65 @@ function asDeletedPlaceholder(message, deletedAt) {
|
|
|
2490
2683
|
};
|
|
2491
2684
|
}
|
|
2492
2685
|
|
|
2686
|
+
/** A message's words on one line; replies written in Workstation may only carry HTML. */
|
|
2687
|
+
function plainTextOf(message) {
|
|
2688
|
+
return (message.bodyPlain || stripHtml(message.body ?? '')).replace(/\s+/g, ' ').trim();
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
/**
|
|
2692
|
+
* The newest message of a thread as this device saw it. `at` is its
|
|
2693
|
+
* `createdAt`, which Workstation's list repeats as `lastMessageAt`; for a sent
|
|
2694
|
+
* message it's the phone's clock instead.
|
|
2695
|
+
*/
|
|
2696
|
+
|
|
2697
|
+
/** `snippet` is absent when there's nothing to quote: the row keeps its words. */
|
|
2698
|
+
|
|
2699
|
+
/** The list row once `message` is the newest. Null keeps the row as it is: system rows and notes. */
|
|
2700
|
+
function rowFieldsOf(message) {
|
|
2701
|
+
if (message.messageType && message.messageType !== 'message') return null;
|
|
2702
|
+
return rowFieldsFor(plainTextOf(message), (message.attachments ?? []).map(attachment => attachment.contentType), message.senderType === 'visitor' || message.direction === 'inbound', /<img\b/i.test(message.body ?? ''));
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
/**
|
|
2706
|
+
* Its words, or with none, whether it carried only photos or other files too.
|
|
2707
|
+
* A message with neither (a card) still says who sent it, so a teammate's
|
|
2708
|
+
* card never leaves "You:" on the visitor's earlier message.
|
|
2709
|
+
*/
|
|
2710
|
+
function rowFieldsFor(text, contentTypes, fromVisitor, inlinePhoto = false) {
|
|
2711
|
+
if (text) return {
|
|
2712
|
+
snippet: text,
|
|
2713
|
+
lastMessageAttachment: null,
|
|
2714
|
+
lastMessageFromVisitor: fromVisitor
|
|
2715
|
+
};
|
|
2716
|
+
const count = contentTypes.length;
|
|
2717
|
+
if (count === 0 && !inlinePhoto) return {
|
|
2718
|
+
lastMessageAttachment: null,
|
|
2719
|
+
lastMessageFromVisitor: fromVisitor
|
|
2720
|
+
};
|
|
2721
|
+
return {
|
|
2722
|
+
// Workstation's list words it this way too, so apps reading `snippet` see the same after a reload.
|
|
2723
|
+
...(count > 0 ? {
|
|
2724
|
+
snippet: `${count} attachment${count === 1 ? '' : 's'}`
|
|
2725
|
+
} : {}),
|
|
2726
|
+
lastMessageAttachment: contentTypes.every(type => isImageAttachment(type)) ? 'photo' : 'file',
|
|
2727
|
+
lastMessageFromVisitor: fromVisitor
|
|
2728
|
+
};
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
/**
|
|
2732
|
+
* The list stamps a conversation just after storing its message, so its time
|
|
2733
|
+
* trails the message's own by a few milliseconds, never more than `LIST_LAG_MS`.
|
|
2734
|
+
* A sent message is timed by the phone instead: a little drift either way
|
|
2735
|
+
* still matches, but only when the list rules out a team reply the visitor
|
|
2736
|
+
* hasn't read. Older Workstation deployments don't send `unreadCount`, so
|
|
2737
|
+
* there a sent message must match like any other.
|
|
2738
|
+
*/
|
|
2739
|
+
function describesSameMessage(conversation, known) {
|
|
2740
|
+
const lag = Date.parse(conversation.lastMessageAt ?? '') - Date.parse(known.at);
|
|
2741
|
+
if (lag >= 0 && lag <= LIST_LAG_MS) return true;
|
|
2742
|
+
return !!known.phoneClock && Math.abs(lag) <= SAME_MESSAGE_DRIFT_MS && conversation.unreadCount === 0;
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2493
2745
|
/** A deletion missed while the stream was down shows up on the next re-read. */
|
|
2494
2746
|
function adoptServerDeletions(messages, server) {
|
|
2495
2747
|
const deleted = new Map(server.filter(message => message.deletedAt).map(message => [message.id, message.deletedAt]));
|
|
@@ -2537,13 +2789,31 @@ function mergeAttachments(local, server) {
|
|
|
2537
2789
|
if (local.length !== server.length) {
|
|
2538
2790
|
return server.every(attachment => attachment.url) ? server : local;
|
|
2539
2791
|
}
|
|
2792
|
+
const owns = pairByName(local, server);
|
|
2540
2793
|
const merged = server.map((attachment, index) => {
|
|
2541
|
-
const own =
|
|
2542
|
-
|
|
2794
|
+
const own = owns[index];
|
|
2795
|
+
if (!isLocalAttachment(own) || !attachment.url) return own;
|
|
2796
|
+
rememberLocalPreview(attachment.url, own.url);
|
|
2797
|
+
return attachment;
|
|
2543
2798
|
});
|
|
2544
2799
|
return merged.every((attachment, index) => attachment === local[index]) ? local : merged;
|
|
2545
2800
|
}
|
|
2546
2801
|
|
|
2802
|
+
/**
|
|
2803
|
+
* The local attachment each server one replaces. By name first: Workstation
|
|
2804
|
+
* may store a message's files in another order than they were sent, and a
|
|
2805
|
+
* photo paired with the wrong file would show it for the rest of the session.
|
|
2806
|
+
* Any it renamed pair up in the order left.
|
|
2807
|
+
*/
|
|
2808
|
+
function pairByName(local, server) {
|
|
2809
|
+
const unpaired = [...local];
|
|
2810
|
+
const named = server.map(attachment => {
|
|
2811
|
+
const index = unpaired.findIndex(own => own.name === attachment.name);
|
|
2812
|
+
return index < 0 ? null : unpaired.splice(index, 1)[0];
|
|
2813
|
+
});
|
|
2814
|
+
return named.map(own => own ?? unpaired.shift());
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2547
2817
|
/**
|
|
2548
2818
|
* Newer Workstation deployments echo `clientMessageId` on the visitor's own
|
|
2549
2819
|
* rows. Such a row replaces the pending or failed bubble it came from instead
|