@repzo/widget-react-native 1.3.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +137 -5
- package/README.md +64 -12
- package/lib/commonjs/api/client.js +96 -23
- 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/locales.js +12 -3
- package/lib/commonjs/locales.js.map +1 -1
- package/lib/commonjs/state/widget-store.js +277 -30
- 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 +637 -164
- 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 +96 -23
- 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/locales.js +12 -3
- package/lib/module/locales.js.map +1 -1
- package/lib/module/state/widget-store.js +279 -32
- 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 +641 -168
- 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 +12 -4
- 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/locales.d.ts.map +1 -1
- package/lib/typescript/commonjs/state/widget-store.d.ts +40 -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 +41 -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 +12 -4
- 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/locales.d.ts.map +1 -1
- package/lib/typescript/module/state/widget-store.d.ts +40 -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 +41 -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 +116 -34
- 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/locales.ts +12 -3
- package/src/state/widget-store.ts +322 -50
- package/src/storage.ts +2 -0
- package/src/types.ts +41 -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 +721 -197
- 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,13 @@ 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';
|
|
42
53
|
|
|
43
54
|
/** Sent on every init; must match `package.json` (tests/version.test.js checks). */
|
|
44
|
-
const SDK_VERSION = '@repzo/widget-react-native@1.
|
|
55
|
+
const SDK_VERSION = '@repzo/widget-react-native@1.4.0';
|
|
45
56
|
|
|
46
57
|
/** The platform this session reports — mobile sessions get the Mobile SDKs overrides. */
|
|
47
58
|
function sessionPlatform(): 'ios' | 'android' | 'web' {
|
|
@@ -62,6 +73,12 @@ const TYPING_SETTLE_MS = 3_000;
|
|
|
62
73
|
const TYPING_PING_INTERVAL_MS = 4_000;
|
|
63
74
|
/** Guards against the SSE stream replaying messages after a reconnect. */
|
|
64
75
|
const SEEN_MESSAGE_LIMIT = 100;
|
|
76
|
+
/** Threads whose newest message is remembered across launches. */
|
|
77
|
+
const LAST_MESSAGE_LIMIT = 50;
|
|
78
|
+
/** How far the phone's clock may be from Workstation's for a sent message to still match the list. */
|
|
79
|
+
const SAME_MESSAGE_DRIFT_MS = 120_000;
|
|
80
|
+
/** How far the list's `lastMessageAt` may trail the message's own `createdAt` (7 ms seen live). */
|
|
81
|
+
const LIST_LAG_MS = 1_000;
|
|
65
82
|
/** Logout's server calls are best-effort — a dead network must not block sign-out. */
|
|
66
83
|
const LOGOUT_REQUEST_TIMEOUT_MS = 5_000;
|
|
67
84
|
/** A server that 401s every fresh stream must not spin init (10/min limit). */
|
|
@@ -174,6 +191,10 @@ export class WidgetStore {
|
|
|
174
191
|
|
|
175
192
|
/** `boot({ posts })` — Send posts over the app. */
|
|
176
193
|
private postsEnabled = true;
|
|
194
|
+
/** `boot({ photoAlbums })` — a message's photos share one album in the thread. */
|
|
195
|
+
private photoAlbumsEnabled = true;
|
|
196
|
+
/** `boot({ youPrefix })` — "You:" before the visitor's own newest message in the lists. */
|
|
197
|
+
private youPrefixEnabled = true;
|
|
177
198
|
|
|
178
199
|
/** Posts whose view was reported this session (the server also dedupes per day). */
|
|
179
200
|
private viewedPostIds = new Set<string>();
|
|
@@ -236,6 +257,11 @@ export class WidgetStore {
|
|
|
236
257
|
private visitorTypingActive = false;
|
|
237
258
|
|
|
238
259
|
private seenMessageIds: string[] = [];
|
|
260
|
+
|
|
261
|
+
/** Who sent each thread's newest message and what it carried; see `withLastMessage`. */
|
|
262
|
+
private lastMessages: Record<string, LastMessageHint> = {};
|
|
263
|
+
|
|
264
|
+
private lastMessagesLoaded = false;
|
|
239
265
|
/** Statuses (e.g. "read") that arrived for an id before a send response revealed it. */
|
|
240
266
|
private earlyStatuses = new Map<string, string>();
|
|
241
267
|
/** The agent UI action behind a failed bubble, so Retry runs it again too. */
|
|
@@ -321,6 +347,10 @@ export class WidgetStore {
|
|
|
321
347
|
/** Composer identity: explicit opens reset it; a draft receiving its server ID does not. */
|
|
322
348
|
getThreadGeneration = (): number => this.threadGeneration;
|
|
323
349
|
|
|
350
|
+
showsPhotoAlbums = (): boolean => this.photoAlbumsEnabled;
|
|
351
|
+
|
|
352
|
+
showsYouPrefix = (): boolean => this.youPrefixEnabled;
|
|
353
|
+
|
|
324
354
|
private setState(patch: Partial<WidgetState> | ((current: WidgetState) => Partial<WidgetState>)) {
|
|
325
355
|
const previous = this.state;
|
|
326
356
|
const resolved = typeof patch === 'function' ? patch(previous) : patch;
|
|
@@ -394,6 +424,8 @@ export class WidgetStore {
|
|
|
394
424
|
if (options.inAppNotifications !== undefined) {
|
|
395
425
|
this.inAppNotificationsEnabled = options.inAppNotifications !== false;
|
|
396
426
|
}
|
|
427
|
+
if (options.photoAlbums !== undefined) this.photoAlbumsEnabled = options.photoAlbums !== false;
|
|
428
|
+
if (options.youPrefix !== undefined) this.youPrefixEnabled = options.youPrefix !== false;
|
|
397
429
|
if (options.posts !== undefined) {
|
|
398
430
|
const wasEnabled = this.postsEnabled;
|
|
399
431
|
this.postsEnabled = options.posts !== false;
|
|
@@ -555,15 +587,15 @@ export class WidgetStore {
|
|
|
555
587
|
]);
|
|
556
588
|
if (this.isStale(epoch)) return;
|
|
557
589
|
|
|
558
|
-
const conversations = result.conversations.map((conversation) =>
|
|
559
|
-
this.hydrateConversation(conversation)
|
|
560
|
-
);
|
|
561
|
-
|
|
562
590
|
// Server counts win (they include replies pushed while the app was
|
|
563
591
|
// killed); the locally persisted map only covers older Workstation
|
|
564
592
|
// deployments that don't send unreadCount yet.
|
|
565
|
-
const storedUnread = await this.loadUnread();
|
|
593
|
+
const [, storedUnread] = await Promise.all([this.loadLastMessages(epoch), this.loadUnread()]);
|
|
566
594
|
if (this.isStale(epoch)) return;
|
|
595
|
+
const conversations = result.conversations.map((conversation) =>
|
|
596
|
+
this.hydrateConversation(conversation)
|
|
597
|
+
);
|
|
598
|
+
|
|
567
599
|
const unreadByConversation: Record<string, number> = {};
|
|
568
600
|
for (const conversation of conversations) {
|
|
569
601
|
const count =
|
|
@@ -851,11 +883,15 @@ export class WidgetStore {
|
|
|
851
883
|
this.userKeyLoaded = true;
|
|
852
884
|
this.pendingAgentByConversation = {};
|
|
853
885
|
this.seenMessageIds = [];
|
|
886
|
+
this.lastMessages = {};
|
|
887
|
+
this.lastMessagesLoaded = false;
|
|
854
888
|
this.earlyStatuses.clear();
|
|
855
889
|
this.visitorTypingActive = false;
|
|
856
890
|
this.lastTypingPingAt = 0;
|
|
857
891
|
this.launchCardOffered = false;
|
|
858
892
|
this.resetPost();
|
|
893
|
+
clearLocalPreviews();
|
|
894
|
+
clearRetainedState();
|
|
859
895
|
this.setState({ ...USER_SCOPED_STATE });
|
|
860
896
|
|
|
861
897
|
const token = await this.client.getToken();
|
|
@@ -863,6 +899,7 @@ export class WidgetStore {
|
|
|
863
899
|
this.client.clearToken(),
|
|
864
900
|
SecureStorage.removeItem(USER_KEY),
|
|
865
901
|
Storage.removeItem(UNREAD_KEY),
|
|
902
|
+
Storage.removeItem(LAST_MESSAGE_KEY),
|
|
866
903
|
// A new anonymous visitor on this device gets a new tracking identity.
|
|
867
904
|
rotateDeviceId(),
|
|
868
905
|
]);
|
|
@@ -1284,6 +1321,7 @@ export class WidgetStore {
|
|
|
1284
1321
|
|
|
1285
1322
|
// Set inside the updater, read after: `setState` runs synchronously.
|
|
1286
1323
|
let countedAsUnread = false;
|
|
1324
|
+
const rowFields = rowFieldsOf(message);
|
|
1287
1325
|
|
|
1288
1326
|
this.setState((current) => {
|
|
1289
1327
|
const conversations = conversationId
|
|
@@ -1291,10 +1329,7 @@ export class WidgetStore {
|
|
|
1291
1329
|
conversation.id === conversationId
|
|
1292
1330
|
? {
|
|
1293
1331
|
...conversation,
|
|
1294
|
-
|
|
1295
|
-
message.messageType === 'message'
|
|
1296
|
-
? message.bodyPlain || conversation.snippet
|
|
1297
|
-
: conversation.snippet,
|
|
1332
|
+
...rowFields,
|
|
1298
1333
|
lastMessageAt: message.createdAt,
|
|
1299
1334
|
messageCount: conversation.messageCount + 1,
|
|
1300
1335
|
}
|
|
@@ -1336,12 +1371,14 @@ export class WidgetStore {
|
|
|
1336
1371
|
messageId,
|
|
1337
1372
|
senderName: message.senderName || conversation?.agentName || null,
|
|
1338
1373
|
senderAvatar: conversation?.agentAvatar ?? null,
|
|
1339
|
-
preview: (message
|
|
1374
|
+
preview: plainTextOf(message),
|
|
1340
1375
|
hasAttachments: (message.attachments?.length ?? 0) > 0,
|
|
1341
1376
|
},
|
|
1342
1377
|
};
|
|
1343
1378
|
});
|
|
1344
1379
|
|
|
1380
|
+
if (conversationId && rowFields) this.rememberLastMessage(conversationId, message.createdAt, rowFields, 'stream');
|
|
1381
|
+
|
|
1345
1382
|
// The visitor is reading it live — move the server cursor too, or the
|
|
1346
1383
|
// next list/init would badge a reply they already saw.
|
|
1347
1384
|
if ((onScreen || readInPost) && isTeamReply && conversationId) this.scheduleMarkRead(conversationId);
|
|
@@ -1438,10 +1475,116 @@ export class WidgetStore {
|
|
|
1438
1475
|
}
|
|
1439
1476
|
|
|
1440
1477
|
private hydrateConversation(conversation: Conversation): Conversation {
|
|
1441
|
-
return this.applyConversationAgent(
|
|
1478
|
+
return this.applyConversationAgent(
|
|
1479
|
+
this.withLastMessage({
|
|
1480
|
+
...conversation,
|
|
1481
|
+
agentAvatar: this.client.resolveUrl(conversation.agentAvatar),
|
|
1482
|
+
})
|
|
1483
|
+
);
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
/**
|
|
1487
|
+
* Workstation's list doesn't say who sent the newest message, and calls one
|
|
1488
|
+
* without words "2 attachments". While a row still describes the message
|
|
1489
|
+
* this device saw last, both are filled in from it. `snippet` keeps the
|
|
1490
|
+
* list's words: apps read it, and the lists draw "Photo" or "File" from
|
|
1491
|
+
* `lastMessageAttachment` instead.
|
|
1492
|
+
*/
|
|
1493
|
+
private withLastMessage(conversation: Conversation): Conversation {
|
|
1494
|
+
const known = this.lastMessages[conversation.id];
|
|
1495
|
+
if (!known || !describesSameMessage(conversation, known)) return conversation;
|
|
1496
|
+
// A list that says who sent it, or what it carried, is used as is.
|
|
1497
|
+
return {
|
|
1442
1498
|
...conversation,
|
|
1443
|
-
|
|
1499
|
+
lastMessageFromVisitor: conversation.lastMessageFromVisitor ?? known.fromVisitor,
|
|
1500
|
+
lastMessageAttachment:
|
|
1501
|
+
conversation.lastMessageAttachment !== undefined ? conversation.lastMessageAttachment : known.attachment,
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
/**
|
|
1506
|
+
* A send or a streamed message is the newest by arrival, whatever the two
|
|
1507
|
+
* clocks say; a history page only replaces something older. A sent message
|
|
1508
|
+
* is timed by the phone, which may run ahead of Workstation, so instead a
|
|
1509
|
+
* page that has it replaces it: whatever is newest there came after it, and
|
|
1510
|
+
* Workstation's own time then stands in for the phone's.
|
|
1511
|
+
*/
|
|
1512
|
+
private rememberLastMessage(
|
|
1513
|
+
conversationId: string,
|
|
1514
|
+
at: string,
|
|
1515
|
+
fields: LastMessageFields,
|
|
1516
|
+
source: 'sent' | 'stream' | 'history',
|
|
1517
|
+
{ messageId, pageIds }: { messageId?: string; pageIds?: ReadonlySet<string> } = {}
|
|
1518
|
+
): void {
|
|
1519
|
+
const known = this.lastMessages[conversationId];
|
|
1520
|
+
if (source === 'history' && known) {
|
|
1521
|
+
const outdated =
|
|
1522
|
+
known.phoneClock && known.messageId
|
|
1523
|
+
? !pageIds?.has(known.messageId)
|
|
1524
|
+
: Date.parse(known.at) > Date.parse(at);
|
|
1525
|
+
if (outdated) return;
|
|
1526
|
+
}
|
|
1527
|
+
const hint: LastMessageHint = {
|
|
1528
|
+
at,
|
|
1529
|
+
fromVisitor: !!fields.lastMessageFromVisitor,
|
|
1530
|
+
attachment: fields.lastMessageAttachment ?? null,
|
|
1531
|
+
...(source === 'sent' ? { phoneClock: true, ...(messageId ? { messageId } : {}) } : {}),
|
|
1532
|
+
};
|
|
1533
|
+
this.lastMessages = Object.fromEntries(
|
|
1534
|
+
Object.entries({ ...this.lastMessages, [conversationId]: hint })
|
|
1535
|
+
.sort(([, a], [, b]) => Date.parse(b.at) - Date.parse(a.at))
|
|
1536
|
+
.slice(0, LAST_MESSAGE_LIMIT)
|
|
1537
|
+
);
|
|
1538
|
+
// Until the stored hints are read, saving would overwrite them with these alone.
|
|
1539
|
+
if (this.lastMessagesLoaded) void Storage.setItem(LAST_MESSAGE_KEY, JSON.stringify(this.lastMessages));
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
private async loadLastMessages(epoch: number): Promise<void> {
|
|
1543
|
+
if (this.lastMessagesLoaded) return;
|
|
1544
|
+
let stored: unknown = null;
|
|
1545
|
+
try {
|
|
1546
|
+
stored = JSON.parse((await Storage.getItem(LAST_MESSAGE_KEY)) ?? 'null');
|
|
1547
|
+
} catch {
|
|
1548
|
+
stored = null;
|
|
1549
|
+
}
|
|
1550
|
+
// Left unloaded: the init that replaces this one reads them again.
|
|
1551
|
+
if (this.isStale(epoch) || this.lastMessagesLoaded) return;
|
|
1552
|
+
this.lastMessagesLoaded = true;
|
|
1553
|
+
const learned = Object.keys(this.lastMessages).length > 0;
|
|
1554
|
+
if (stored && typeof stored === 'object') {
|
|
1555
|
+
for (const [conversationId, hint] of Object.entries(stored as Record<string, Partial<LastMessageHint>>)) {
|
|
1556
|
+
if (typeof hint?.at !== 'string' || typeof hint.fromVisitor !== 'boolean') continue;
|
|
1557
|
+
const known = this.lastMessages[conversationId];
|
|
1558
|
+
if (known && Date.parse(known.at) >= Date.parse(hint.at)) continue;
|
|
1559
|
+
this.lastMessages[conversationId] = {
|
|
1560
|
+
at: hint.at,
|
|
1561
|
+
fromVisitor: hint.fromVisitor,
|
|
1562
|
+
attachment: hint.attachment === 'photo' || hint.attachment === 'file' ? hint.attachment : null,
|
|
1563
|
+
...(hint.phoneClock === true ? { phoneClock: true } : {}),
|
|
1564
|
+
...(typeof hint.messageId === 'string' ? { messageId: hint.messageId } : {}),
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
// Hints learned while the stored ones were being read are saved with them.
|
|
1569
|
+
if (learned) void Storage.setItem(LAST_MESSAGE_KEY, JSON.stringify(this.lastMessages));
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
/** A thread's newest page shows who sent its newest message, so its row can say too. */
|
|
1573
|
+
private learnLastMessage(conversationId: string, page: Message[]): void {
|
|
1574
|
+
let newest: Message | null = null;
|
|
1575
|
+
for (const message of page) {
|
|
1576
|
+
if (!newest || Date.parse(message.createdAt) > Date.parse(newest.createdAt)) newest = message;
|
|
1577
|
+
}
|
|
1578
|
+
const fields = newest && rowFieldsOf(newest);
|
|
1579
|
+
if (!newest || !fields) return;
|
|
1580
|
+
this.rememberLastMessage(conversationId, newest.createdAt, fields, 'history', {
|
|
1581
|
+
pageIds: new Set(page.map((message) => message.id)),
|
|
1444
1582
|
});
|
|
1583
|
+
this.setState((current) => ({
|
|
1584
|
+
conversations: current.conversations.map((row) =>
|
|
1585
|
+
row.id === conversationId ? this.withLastMessage(row) : row
|
|
1586
|
+
),
|
|
1587
|
+
}));
|
|
1445
1588
|
}
|
|
1446
1589
|
|
|
1447
1590
|
/**
|
|
@@ -1452,21 +1595,30 @@ export class WidgetStore {
|
|
|
1452
1595
|
if (!Array.isArray(attachments) || attachments.length === 0) return null;
|
|
1453
1596
|
const hydrated = attachments
|
|
1454
1597
|
.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
|
-
|
|
1598
|
+
.map((attachment, index) => {
|
|
1599
|
+
const name =
|
|
1600
|
+
typeof attachment.name === 'string' && attachment.name ? attachment.name : 'Attachment';
|
|
1601
|
+
const declared = typeof attachment.contentType === 'string' ? attachment.contentType : '';
|
|
1602
|
+
return {
|
|
1603
|
+
...attachment,
|
|
1604
|
+
id:
|
|
1605
|
+
typeof attachment.id === 'string' || typeof attachment.id === 'number'
|
|
1606
|
+
? String(attachment.id)
|
|
1607
|
+
: `${messageId ?? 'attachment'}-${index}`,
|
|
1608
|
+
name,
|
|
1609
|
+
// Without a type (or with a generic one) the name says what it is,
|
|
1610
|
+
// so a photo still shows as a photo rather than a file row.
|
|
1611
|
+
contentType:
|
|
1612
|
+
declared && !/^application\/octet-stream$/i.test(declared)
|
|
1613
|
+
? declared
|
|
1614
|
+
: (guessContentType(name) ?? declared),
|
|
1615
|
+
size: typeof attachment.size === 'number' ? attachment.size : 0,
|
|
1616
|
+
// Empty (never the raw value) when the URL isn't http(s) — the preview
|
|
1617
|
+
// then renders the name only, with nothing to open.
|
|
1618
|
+
url:
|
|
1619
|
+
this.client.resolveUrl(typeof attachment.url === 'string' ? attachment.url : null) ?? '',
|
|
1620
|
+
};
|
|
1621
|
+
});
|
|
1470
1622
|
return hydrated.length > 0 ? hydrated : null;
|
|
1471
1623
|
}
|
|
1472
1624
|
|
|
@@ -1707,10 +1859,10 @@ export class WidgetStore {
|
|
|
1707
1859
|
const result = await this.client.listMessages(conversationId);
|
|
1708
1860
|
// Opening A then B quickly: A's page must never land under B.
|
|
1709
1861
|
if (this.isStale(epoch) || this.threadGeneration !== generation) return;
|
|
1862
|
+
const page = this.hydrateMessages(result.data);
|
|
1710
1863
|
this.setState((current) => {
|
|
1711
1864
|
// Live replies and optimistic sends may have arrived since this page
|
|
1712
1865
|
// was requested. Keep them, preferring the live copy for shared ids.
|
|
1713
|
-
const page = this.hydrateMessages(result.data);
|
|
1714
1866
|
const live = adoptOwnServerRows(current.messages, page);
|
|
1715
1867
|
const liveIds = new Set(live.map((message) => message.id));
|
|
1716
1868
|
const history = page.filter((message) => !liveIds.has(message.id));
|
|
@@ -1721,6 +1873,7 @@ export class WidgetStore {
|
|
|
1721
1873
|
messageCursor: result.meta.nextCursor,
|
|
1722
1874
|
};
|
|
1723
1875
|
});
|
|
1876
|
+
this.learnLastMessage(conversationId, page);
|
|
1724
1877
|
} catch {
|
|
1725
1878
|
if (!this.isStale(epoch) && this.threadGeneration === generation) {
|
|
1726
1879
|
this.setState({ messagesLoading: false });
|
|
@@ -1778,6 +1931,7 @@ export class WidgetStore {
|
|
|
1778
1931
|
if (this.state.activeConversationId !== conversationId) return;
|
|
1779
1932
|
|
|
1780
1933
|
const fresh = this.hydrateMessages(result.data);
|
|
1934
|
+
this.learnLastMessage(conversationId, fresh);
|
|
1781
1935
|
this.setState((current) => {
|
|
1782
1936
|
const own = adoptOwnServerRows(current.messages, fresh);
|
|
1783
1937
|
const known = new Set(own.map((message) => message.id));
|
|
@@ -1956,6 +2110,18 @@ export class WidgetStore {
|
|
|
1956
2110
|
return this.deliverMessage(tempId, trimmed, attachments, agentUiAction);
|
|
1957
2111
|
}
|
|
1958
2112
|
|
|
2113
|
+
/**
|
|
2114
|
+
* Puts a picked file in storage while the visitor is still writing, so
|
|
2115
|
+
* Send goes out at once. Resolves the token `sendMessage` names it by, and
|
|
2116
|
+
* when that token expires.
|
|
2117
|
+
*/
|
|
2118
|
+
uploadAttachment(
|
|
2119
|
+
file: { uri: string; name: string; contentType: string; size: number },
|
|
2120
|
+
signal?: AbortSignal
|
|
2121
|
+
): Promise<UploadedFile> {
|
|
2122
|
+
return this.client.uploadAttachment(file, signal);
|
|
2123
|
+
}
|
|
2124
|
+
|
|
1959
2125
|
/**
|
|
1960
2126
|
* Re-sends a text bubble that failed. It moves to the end of the thread,
|
|
1961
2127
|
* since it now goes out after anything sent meanwhile. Rejects like
|
|
@@ -2023,17 +2189,21 @@ export class WidgetStore {
|
|
|
2023
2189
|
|
|
2024
2190
|
let createdId: string | null = null;
|
|
2025
2191
|
try {
|
|
2026
|
-
// Local files go up first
|
|
2192
|
+
// Local files go up first, unless the composer already put them up; the
|
|
2193
|
+
// message names each by its upload token.
|
|
2027
2194
|
const sentAttachments = await Promise.all(
|
|
2028
|
-
attachments.map(async ({ name, url, fileUri, contentType, size }) =>
|
|
2029
|
-
|
|
2030
|
-
? {
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2195
|
+
attachments.map(async ({ name, url, fileUri, uploadToken, contentType, size }) =>
|
|
2196
|
+
uploadToken
|
|
2197
|
+
? { name, contentType, size, uploadToken }
|
|
2198
|
+
: fileUri
|
|
2199
|
+
? {
|
|
2200
|
+
name,
|
|
2201
|
+
contentType,
|
|
2202
|
+
size,
|
|
2203
|
+
uploadToken: (await this.client.uploadAttachment({ uri: fileUri, name, contentType, size }))
|
|
2204
|
+
.uploadToken,
|
|
2205
|
+
}
|
|
2206
|
+
: { name, url, contentType, size }
|
|
2037
2207
|
)
|
|
2038
2208
|
);
|
|
2039
2209
|
if (this.isStale(epoch)) return;
|
|
@@ -2052,6 +2222,10 @@ export class WidgetStore {
|
|
|
2052
2222
|
if (this.isStale(epoch)) return;
|
|
2053
2223
|
if (ownCreation) createdId = result.conversationId;
|
|
2054
2224
|
|
|
2225
|
+
// Workstation doesn't stream the visitor's own messages back, so the
|
|
2226
|
+
// list row learns about this one here.
|
|
2227
|
+
const sentAt = new Date().toISOString();
|
|
2228
|
+
const sentRow = rowFieldsFor(trimmed, attachments.map((attachment) => attachment.contentType), true);
|
|
2055
2229
|
this.setState((current) => {
|
|
2056
2230
|
// The stream or a catch-up fetch may have delivered this message
|
|
2057
2231
|
// already — keep one copy, never two bubbles with the same id.
|
|
@@ -2064,19 +2238,27 @@ export class WidgetStore {
|
|
|
2064
2238
|
: message
|
|
2065
2239
|
);
|
|
2066
2240
|
|
|
2067
|
-
if (!createdId)
|
|
2241
|
+
if (!createdId) {
|
|
2242
|
+
return {
|
|
2243
|
+
messages,
|
|
2244
|
+
conversations: current.conversations.map((row) =>
|
|
2245
|
+
row.id === result.conversationId
|
|
2246
|
+
? { ...row, ...sentRow, lastMessageAt: sentAt, messageCount: row.messageCount + (delivered ? 0 : 1) }
|
|
2247
|
+
: row
|
|
2248
|
+
),
|
|
2249
|
+
};
|
|
2250
|
+
}
|
|
2068
2251
|
|
|
2069
2252
|
const known = current.conversations.some((item) => item.id === result.conversationId);
|
|
2070
2253
|
const conversation: Conversation = {
|
|
2071
2254
|
id: result.conversationId,
|
|
2072
2255
|
subject: null,
|
|
2073
2256
|
status: 'open',
|
|
2074
|
-
snippet:
|
|
2075
|
-
|
|
2076
|
-
: `${attachments.length} attachment${attachments.length === 1 ? '' : 's'}`,
|
|
2257
|
+
snippet: null,
|
|
2258
|
+
...sentRow,
|
|
2077
2259
|
messageCount: 1,
|
|
2078
|
-
lastMessageAt:
|
|
2079
|
-
createdAt:
|
|
2260
|
+
lastMessageAt: sentAt,
|
|
2261
|
+
createdAt: sentAt,
|
|
2080
2262
|
agentName: null,
|
|
2081
2263
|
agentAvatar: null,
|
|
2082
2264
|
};
|
|
@@ -2092,6 +2274,7 @@ export class WidgetStore {
|
|
|
2092
2274
|
: {}),
|
|
2093
2275
|
};
|
|
2094
2276
|
});
|
|
2277
|
+
this.rememberLastMessage(result.conversationId, sentAt, sentRow, 'sent', { messageId: result.messageId });
|
|
2095
2278
|
|
|
2096
2279
|
// New files to fetch the stored copy of: a fresh round of re-reads.
|
|
2097
2280
|
const sent = this.state.messages.find((message) => message.id === result.messageId);
|
|
@@ -2593,6 +2776,77 @@ function asDeletedPlaceholder(message: Message, deletedAt: string): Message {
|
|
|
2593
2776
|
return { ...message, body: null, bodyPlain: null, attachments: null, metadata: null, deletedAt };
|
|
2594
2777
|
}
|
|
2595
2778
|
|
|
2779
|
+
/** A message's words on one line; replies written in Workstation may only carry HTML. */
|
|
2780
|
+
function plainTextOf(message: Message): string {
|
|
2781
|
+
return (message.bodyPlain || stripHtml(message.body ?? '')).replace(/\s+/g, ' ').trim();
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
/**
|
|
2785
|
+
* The newest message of a thread as this device saw it. `at` is its
|
|
2786
|
+
* `createdAt`, which Workstation's list repeats as `lastMessageAt`; for a sent
|
|
2787
|
+
* message it's the phone's clock instead.
|
|
2788
|
+
*/
|
|
2789
|
+
interface LastMessageHint {
|
|
2790
|
+
at: string;
|
|
2791
|
+
fromVisitor: boolean;
|
|
2792
|
+
attachment: 'photo' | 'file' | null;
|
|
2793
|
+
phoneClock?: boolean;
|
|
2794
|
+
/** A sent message's id: a page from Workstation that has it can retime it. */
|
|
2795
|
+
messageId?: string;
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
/** `snippet` is absent when there's nothing to quote: the row keeps its words. */
|
|
2799
|
+
type LastMessageFields = Pick<Conversation, 'lastMessageAttachment' | 'lastMessageFromVisitor'> & {
|
|
2800
|
+
snippet?: string;
|
|
2801
|
+
};
|
|
2802
|
+
|
|
2803
|
+
/** The list row once `message` is the newest. Null keeps the row as it is: system rows and notes. */
|
|
2804
|
+
function rowFieldsOf(message: Message): LastMessageFields | null {
|
|
2805
|
+
if (message.messageType && message.messageType !== 'message') return null;
|
|
2806
|
+
return rowFieldsFor(
|
|
2807
|
+
plainTextOf(message),
|
|
2808
|
+
(message.attachments ?? []).map((attachment) => attachment.contentType),
|
|
2809
|
+
message.senderType === 'visitor' || message.direction === 'inbound',
|
|
2810
|
+
/<img\b/i.test(message.body ?? '')
|
|
2811
|
+
);
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
/**
|
|
2815
|
+
* Its words, or with none, whether it carried only photos or other files too.
|
|
2816
|
+
* A message with neither (a card) still says who sent it, so a teammate's
|
|
2817
|
+
* card never leaves "You:" on the visitor's earlier message.
|
|
2818
|
+
*/
|
|
2819
|
+
function rowFieldsFor(
|
|
2820
|
+
text: string,
|
|
2821
|
+
contentTypes: Array<string | null | undefined>,
|
|
2822
|
+
fromVisitor: boolean,
|
|
2823
|
+
inlinePhoto = false
|
|
2824
|
+
): LastMessageFields {
|
|
2825
|
+
if (text) return { snippet: text, lastMessageAttachment: null, lastMessageFromVisitor: fromVisitor };
|
|
2826
|
+
const count = contentTypes.length;
|
|
2827
|
+
if (count === 0 && !inlinePhoto) return { lastMessageAttachment: null, lastMessageFromVisitor: fromVisitor };
|
|
2828
|
+
return {
|
|
2829
|
+
// Workstation's list words it this way too, so apps reading `snippet` see the same after a reload.
|
|
2830
|
+
...(count > 0 ? { snippet: `${count} attachment${count === 1 ? '' : 's'}` } : {}),
|
|
2831
|
+
lastMessageAttachment: contentTypes.every((type) => isImageAttachment(type)) ? 'photo' : 'file',
|
|
2832
|
+
lastMessageFromVisitor: fromVisitor,
|
|
2833
|
+
};
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
/**
|
|
2837
|
+
* The list stamps a conversation just after storing its message, so its time
|
|
2838
|
+
* trails the message's own by a few milliseconds, never more than `LIST_LAG_MS`.
|
|
2839
|
+
* A sent message is timed by the phone instead: a little drift either way
|
|
2840
|
+
* still matches, but only when the list rules out a team reply the visitor
|
|
2841
|
+
* hasn't read. Older Workstation deployments don't send `unreadCount`, so
|
|
2842
|
+
* there a sent message must match like any other.
|
|
2843
|
+
*/
|
|
2844
|
+
function describesSameMessage(conversation: Conversation, known: LastMessageHint): boolean {
|
|
2845
|
+
const lag = Date.parse(conversation.lastMessageAt ?? '') - Date.parse(known.at);
|
|
2846
|
+
if (lag >= 0 && lag <= LIST_LAG_MS) return true;
|
|
2847
|
+
return !!known.phoneClock && Math.abs(lag) <= SAME_MESSAGE_DRIFT_MS && conversation.unreadCount === 0;
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2596
2850
|
/** A deletion missed while the stream was down shows up on the next re-read. */
|
|
2597
2851
|
function adoptServerDeletions(messages: Message[], server: Message[]): Message[] {
|
|
2598
2852
|
const deleted = new Map(
|
|
@@ -2641,13 +2895,31 @@ function mergeAttachments(local: WidgetAttachment[], server: WidgetAttachment[])
|
|
|
2641
2895
|
if (local.length !== server.length) {
|
|
2642
2896
|
return server.every((attachment) => attachment.url) ? server : local;
|
|
2643
2897
|
}
|
|
2898
|
+
const owns = pairByName(local, server);
|
|
2644
2899
|
const merged = server.map((attachment, index) => {
|
|
2645
|
-
const own =
|
|
2646
|
-
|
|
2900
|
+
const own = owns[index]!;
|
|
2901
|
+
if (!isLocalAttachment(own) || !attachment.url) return own;
|
|
2902
|
+
rememberLocalPreview(attachment.url, own.url);
|
|
2903
|
+
return attachment;
|
|
2647
2904
|
});
|
|
2648
2905
|
return merged.every((attachment, index) => attachment === local[index]) ? local : merged;
|
|
2649
2906
|
}
|
|
2650
2907
|
|
|
2908
|
+
/**
|
|
2909
|
+
* The local attachment each server one replaces. By name first: Workstation
|
|
2910
|
+
* may store a message's files in another order than they were sent, and a
|
|
2911
|
+
* photo paired with the wrong file would show it for the rest of the session.
|
|
2912
|
+
* Any it renamed pair up in the order left.
|
|
2913
|
+
*/
|
|
2914
|
+
function pairByName(local: WidgetAttachment[], server: WidgetAttachment[]): WidgetAttachment[] {
|
|
2915
|
+
const unpaired = [...local];
|
|
2916
|
+
const named = server.map((attachment) => {
|
|
2917
|
+
const index = unpaired.findIndex((own) => own.name === attachment.name);
|
|
2918
|
+
return index < 0 ? null : unpaired.splice(index, 1)[0]!;
|
|
2919
|
+
});
|
|
2920
|
+
return named.map((own) => own ?? unpaired.shift()!);
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2651
2923
|
/**
|
|
2652
2924
|
* Newer Workstation deployments echo `clientMessageId` on the visitor's own
|
|
2653
2925
|
* 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
|
package/src/types.ts
CHANGED
|
@@ -104,6 +104,8 @@ export interface OutgoingAttachment {
|
|
|
104
104
|
url: string;
|
|
105
105
|
/** A local file uploaded before the message is sent (servers with `maxAttachmentBytes`). */
|
|
106
106
|
fileUri?: string;
|
|
107
|
+
/** `fileUri` is already uploaded under this token, so the send skips uploading it. */
|
|
108
|
+
uploadToken?: string;
|
|
107
109
|
contentType: string;
|
|
108
110
|
/** Decoded bytes. */
|
|
109
111
|
size: number;
|
|
@@ -116,6 +118,21 @@ export interface Conversation {
|
|
|
116
118
|
subject: string | null;
|
|
117
119
|
status: string;
|
|
118
120
|
snippet: string | null;
|
|
121
|
+
/**
|
|
122
|
+
* Set when the newest message had no words, only photos (`'photo'`) or
|
|
123
|
+
* other files (`'file'`); the lists then say "Photo" or "File", while
|
|
124
|
+
* `snippet` keeps Workstation's wording ("2 attachments"). `null` when it
|
|
125
|
+
* had words. Filled in from what this device sent, received or loaded.
|
|
126
|
+
* @since 1.4.0
|
|
127
|
+
*/
|
|
128
|
+
lastMessageAttachment?: 'photo' | 'file' | null;
|
|
129
|
+
/**
|
|
130
|
+
* The newest message is the visitor's own; the lists put "You:" before it.
|
|
131
|
+
* A Workstation list that includes it is used as is; otherwise the SDK fills
|
|
132
|
+
* it in from what this device sent, received or loaded.
|
|
133
|
+
* @since 1.4.0
|
|
134
|
+
*/
|
|
135
|
+
lastMessageFromVisitor?: boolean | null;
|
|
119
136
|
messageCount: number;
|
|
120
137
|
lastMessageAt: string | null;
|
|
121
138
|
createdAt: string;
|
|
@@ -267,6 +284,22 @@ export interface BootOptions {
|
|
|
267
284
|
* @since 1.2.0
|
|
268
285
|
*/
|
|
269
286
|
posts?: boolean;
|
|
287
|
+
/**
|
|
288
|
+
* Show two or more photos in one message together as an album: one square
|
|
289
|
+
* holding them all, with "+N" on the last tile past four (default true).
|
|
290
|
+
* False shows each photo on its own, full width, as before 1.4.0. Either
|
|
291
|
+
* way, tapping a photo opens the message's photos in the viewer.
|
|
292
|
+
* @since 1.4.0
|
|
293
|
+
*/
|
|
294
|
+
photoAlbums?: boolean;
|
|
295
|
+
/**
|
|
296
|
+
* Put "You: " before a conversation's newest message on Home and in
|
|
297
|
+
* Messages when the visitor sent it (default true; Arabic "أنت: ", the
|
|
298
|
+
* `lastMessageYou` translation key). False shows the message alone, as
|
|
299
|
+
* before 1.4.0. `Conversation.lastMessageFromVisitor` is filled in either way.
|
|
300
|
+
* @since 1.4.0
|
|
301
|
+
*/
|
|
302
|
+
youPrefix?: boolean;
|
|
270
303
|
}
|
|
271
304
|
|
|
272
305
|
/** An option in the composer's attach menu. */
|
|
@@ -303,6 +336,14 @@ export interface RepzoWidgetAttachmentOptions {
|
|
|
303
336
|
pick?: (source: AttachmentSource) => Promise<RepzoPickedFile[] | null>;
|
|
304
337
|
/** Menu options offered with `pick`. Default all three. Ignored without `pick`. */
|
|
305
338
|
sources?: AttachmentSource[];
|
|
339
|
+
/**
|
|
340
|
+
* Default true: with a Workstation that takes uploads, a picked file starts
|
|
341
|
+
* uploading right away, while the visitor writes, so the message goes out
|
|
342
|
+
* the moment they tap Send. False uploads the files only when they tap
|
|
343
|
+
* Send, so nothing leaves the phone for a file they remove first.
|
|
344
|
+
* @since 1.4.0
|
|
345
|
+
*/
|
|
346
|
+
uploadOnPick?: boolean;
|
|
306
347
|
}
|
|
307
348
|
|
|
308
349
|
// ─── Agent UI blocks ────────────────────────────────────────────────────────
|