@repzo/widget-react-native 1.2.2 → 1.3.1
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 +88 -0
- package/README.md +43 -33
- package/android/build.gradle +22 -0
- package/android/src/main/java/expo/modules/repzowidget/RepzoVoicePlayer.kt +222 -0
- package/android/src/main/java/expo/modules/repzowidget/RepzoWidgetAudioModule.kt +68 -0
- package/expo-module.config.json +9 -0
- package/ios/RepzoVoicePlayer.swift +175 -0
- package/ios/RepzoWidgetAudio.podspec +26 -0
- package/ios/RepzoWidgetAudioModule.swift +65 -0
- package/lib/commonjs/api/client.js +40 -0
- package/lib/commonjs/api/client.js.map +1 -1
- package/lib/commonjs/attachments/pickers.js +80 -23
- package/lib/commonjs/attachments/pickers.js.map +1 -1
- package/lib/commonjs/audio/native-player.js +97 -0
- package/lib/commonjs/audio/native-player.js.map +1 -0
- package/lib/commonjs/audio/voice.js +34 -7
- package/lib/commonjs/audio/voice.js.map +1 -1
- package/lib/commonjs/i18n.js +2 -0
- package/lib/commonjs/i18n.js.map +1 -1
- package/lib/commonjs/locales.js +1 -0
- package/lib/commonjs/locales.js.map +1 -1
- package/lib/commonjs/state/widget-store.js +238 -40
- package/lib/commonjs/state/widget-store.js.map +1 -1
- package/lib/commonjs/types.js.map +1 -1
- package/lib/commonjs/ui/WidgetProvider.js +8 -6
- package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
- package/lib/commonjs/ui/components/ImageViewer.js +20 -37
- package/lib/commonjs/ui/components/ImageViewer.js.map +1 -1
- package/lib/commonjs/ui/components/MessagesSkeleton.js +126 -0
- package/lib/commonjs/ui/components/MessagesSkeleton.js.map +1 -0
- package/lib/commonjs/ui/components/VoiceNote.js +8 -9
- package/lib/commonjs/ui/components/VoiceNote.js.map +1 -1
- package/lib/commonjs/ui/components/ZoomableImage.js +299 -0
- package/lib/commonjs/ui/components/ZoomableImage.js.map +1 -0
- package/lib/commonjs/ui/components/icons.js +22 -16
- package/lib/commonjs/ui/components/icons.js.map +1 -1
- package/lib/commonjs/ui/screens/chat/ChatView.js +42 -18
- package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js +17 -9
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
- package/lib/commonjs/ui/styles/theme.js +1 -0
- package/lib/commonjs/ui/styles/theme.js.map +1 -1
- package/lib/commonjs/ui/utils/conversations.js +17 -1
- package/lib/commonjs/ui/utils/conversations.js.map +1 -1
- package/lib/module/api/client.js +40 -0
- package/lib/module/api/client.js.map +1 -1
- package/lib/module/attachments/pickers.js +80 -23
- package/lib/module/attachments/pickers.js.map +1 -1
- package/lib/module/audio/native-player.js +93 -0
- package/lib/module/audio/native-player.js.map +1 -0
- package/lib/module/audio/voice.js +32 -6
- package/lib/module/audio/voice.js.map +1 -1
- package/lib/module/i18n.js +2 -0
- package/lib/module/i18n.js.map +1 -1
- package/lib/module/locales.js +1 -0
- package/lib/module/locales.js.map +1 -1
- package/lib/module/state/widget-store.js +238 -40
- package/lib/module/state/widget-store.js.map +1 -1
- package/lib/module/types.js.map +1 -1
- package/lib/module/ui/WidgetProvider.js +8 -6
- package/lib/module/ui/WidgetProvider.js.map +1 -1
- package/lib/module/ui/components/ImageViewer.js +21 -38
- package/lib/module/ui/components/ImageViewer.js.map +1 -1
- package/lib/module/ui/components/MessagesSkeleton.js +124 -0
- package/lib/module/ui/components/MessagesSkeleton.js.map +1 -0
- package/lib/module/ui/components/VoiceNote.js +9 -10
- package/lib/module/ui/components/VoiceNote.js.map +1 -1
- package/lib/module/ui/components/ZoomableImage.js +291 -0
- package/lib/module/ui/components/ZoomableImage.js.map +1 -0
- package/lib/module/ui/components/icons.js +21 -15
- package/lib/module/ui/components/icons.js.map +1 -1
- package/lib/module/ui/screens/chat/ChatView.js +44 -20
- package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/module/ui/screens/chat/ChatView.styles.js +17 -9
- package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
- package/lib/module/ui/styles/theme.js +1 -0
- package/lib/module/ui/styles/theme.js.map +1 -1
- package/lib/module/ui/utils/conversations.js +15 -1
- package/lib/module/ui/utils/conversations.js.map +1 -1
- package/lib/typescript/commonjs/api/client.d.ts +27 -1
- package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
- package/lib/typescript/commonjs/attachments/pickers.d.ts +22 -10
- package/lib/typescript/commonjs/attachments/pickers.d.ts.map +1 -1
- package/lib/typescript/commonjs/audio/native-player.d.ts +36 -0
- package/lib/typescript/commonjs/audio/native-player.d.ts.map +1 -0
- package/lib/typescript/commonjs/audio/voice.d.ts +17 -4
- package/lib/typescript/commonjs/audio/voice.d.ts.map +1 -1
- package/lib/typescript/commonjs/i18n.d.ts +1 -0
- 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 +38 -3
- package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/commonjs/types.d.ts +17 -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/ImageViewer.d.ts +2 -2
- package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/MessagesSkeleton.d.ts +10 -0
- package/lib/typescript/commonjs/ui/components/MessagesSkeleton.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/VoiceNote.d.ts +2 -2
- package/lib/typescript/commonjs/ui/components/VoiceNote.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts +45 -0
- package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/icons.d.ts +2 -1
- package/lib/typescript/commonjs/ui/components/icons.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +14 -9
- 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 +4 -0
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/styles/theme.d.ts +2 -0
- package/lib/typescript/commonjs/ui/styles/theme.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/conversations.d.ts +10 -2
- package/lib/typescript/commonjs/ui/utils/conversations.d.ts.map +1 -1
- package/lib/typescript/module/api/client.d.ts +27 -1
- package/lib/typescript/module/api/client.d.ts.map +1 -1
- package/lib/typescript/module/attachments/pickers.d.ts +22 -10
- package/lib/typescript/module/attachments/pickers.d.ts.map +1 -1
- package/lib/typescript/module/audio/native-player.d.ts +36 -0
- package/lib/typescript/module/audio/native-player.d.ts.map +1 -0
- package/lib/typescript/module/audio/voice.d.ts +17 -4
- package/lib/typescript/module/audio/voice.d.ts.map +1 -1
- package/lib/typescript/module/i18n.d.ts +1 -0
- 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 +38 -3
- package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/module/types.d.ts +17 -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/ImageViewer.d.ts +2 -2
- package/lib/typescript/module/ui/components/ImageViewer.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/MessagesSkeleton.d.ts +10 -0
- package/lib/typescript/module/ui/components/MessagesSkeleton.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/VoiceNote.d.ts +2 -2
- package/lib/typescript/module/ui/components/VoiceNote.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/ZoomableImage.d.ts +45 -0
- package/lib/typescript/module/ui/components/ZoomableImage.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/icons.d.ts +2 -1
- package/lib/typescript/module/ui/components/icons.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.d.ts +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +14 -9
- 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 +4 -0
- package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
- package/lib/typescript/module/ui/styles/theme.d.ts +2 -0
- package/lib/typescript/module/ui/styles/theme.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/conversations.d.ts +10 -2
- package/lib/typescript/module/ui/utils/conversations.d.ts.map +1 -1
- package/package.json +10 -3
- package/react-native.config.js +12 -0
- package/src/api/client.ts +66 -2
- package/src/attachments/pickers.ts +87 -25
- package/src/audio/native-player.ts +106 -0
- package/src/audio/voice.ts +30 -6
- package/src/i18n.ts +2 -0
- package/src/locales.ts +1 -0
- package/src/state/widget-store.ts +233 -33
- package/src/types.ts +17 -0
- package/src/ui/WidgetProvider.tsx +16 -7
- package/src/ui/components/ImageViewer.tsx +21 -36
- package/src/ui/components/MessagesSkeleton.tsx +92 -0
- package/src/ui/components/VoiceNote.tsx +6 -14
- package/src/ui/components/ZoomableImage.tsx +250 -0
- package/src/ui/components/icons.tsx +10 -12
- package/src/ui/screens/chat/ChatView.styles.ts +17 -9
- package/src/ui/screens/chat/ChatView.tsx +49 -25
- package/src/ui/screens/chat/ChatView.types.ts +4 -0
- package/src/ui/styles/theme.ts +6 -0
- package/src/ui/utils/conversations.ts +16 -2
|
@@ -19,10 +19,11 @@ import { PushManager } from "../push/push-notifications.js";
|
|
|
19
19
|
import { CONFIG_KEY, LEGACY_CONFIG_KEY, SecureStorage, Storage, UNREAD_KEY, USER_KEY } from "../storage.js";
|
|
20
20
|
import { setAttachmentOptions } from "../attachments/pickers.js";
|
|
21
21
|
import { DEFAULT_BASE_URL } from "../types.js";
|
|
22
|
+
import { canRetryMessage, isLocalMessage } from "../ui/utils/conversations.js";
|
|
22
23
|
import { setOpenUrlHandler } from "../ui/utils/safe-url.js";
|
|
23
24
|
|
|
24
25
|
/** Sent on every init; must match `package.json` (tests/version.test.js checks). */
|
|
25
|
-
const SDK_VERSION = '@repzo/widget-react-native@1.
|
|
26
|
+
const SDK_VERSION = '@repzo/widget-react-native@1.3.1';
|
|
26
27
|
|
|
27
28
|
/** The platform this session reports — mobile sessions get the Mobile SDKs overrides. */
|
|
28
29
|
function sessionPlatform() {
|
|
@@ -52,6 +53,8 @@ const MARK_READ_DEBOUNCE_MS = 1_000;
|
|
|
52
53
|
const FOREGROUND_REFRESH_AFTER_MS = 5 * 60_000;
|
|
53
54
|
/** Live events held back while a draft's first send is creating its thread. */
|
|
54
55
|
const DRAFT_EVENT_BUFFER_LIMIT = 50;
|
|
56
|
+
/** Waits between re-reads for a sent file's stored copy while Workstation is still storing it. */
|
|
57
|
+
const SERVER_COPY_RETRY_MS = [2_000, 5_000, 15_000];
|
|
55
58
|
/** How long a post stays up after a reaction, thanking them, before it goes. */
|
|
56
59
|
export const POST_THANKS_MS = 1600;
|
|
57
60
|
|
|
@@ -168,6 +171,13 @@ export class WidgetStore {
|
|
|
168
171
|
/** True once `currentUserKey` mirrors storage — memory is then authoritative. */
|
|
169
172
|
userKeyLoaded = false;
|
|
170
173
|
pendingConversationId = null;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The screen a thread was opened from, so its back button returns there.
|
|
177
|
+
* Null when it was opened with the messenger closed (a push, a notification
|
|
178
|
+
* card, `presentConversation`): there is nothing to go back to.
|
|
179
|
+
*/
|
|
180
|
+
chatOpenedFrom = null;
|
|
171
181
|
appStateSubscription = null;
|
|
172
182
|
|
|
173
183
|
/** When the app last went to the background (0: hasn't this session). */
|
|
@@ -177,6 +187,12 @@ export class WidgetStore {
|
|
|
177
187
|
seenMessageIds = [];
|
|
178
188
|
/** Statuses (e.g. "read") that arrived for an id before a send response revealed it. */
|
|
179
189
|
earlyStatuses = new Map();
|
|
190
|
+
/** The agent UI action behind a failed bubble, so Retry runs it again too. */
|
|
191
|
+
failedActions = new Map();
|
|
192
|
+
/** The next re-read for a sent file's stored copy (see `syncServerCopies`). */
|
|
193
|
+
serverCopyTimer = null;
|
|
194
|
+
serverCopyRetries = 0;
|
|
195
|
+
serverCopySyncing = false;
|
|
180
196
|
|
|
181
197
|
/**
|
|
182
198
|
* Identity generation. Bumped whenever boot/login/logout is requested; every
|
|
@@ -266,6 +282,13 @@ export class WidgetStore {
|
|
|
266
282
|
next.inAppNotification = null;
|
|
267
283
|
}
|
|
268
284
|
this.state = next;
|
|
285
|
+
|
|
286
|
+
// Recorded here because a thread opens from many places (home cards, the
|
|
287
|
+
// list, the Messages tab, posts) and each would otherwise have to say so.
|
|
288
|
+
const intoThread = next.view === 'chat' || next.view === 'prechat';
|
|
289
|
+
if (intoThread && previous.view !== 'chat' && previous.view !== 'prechat') {
|
|
290
|
+
this.chatOpenedFrom = previous.isOpen && (previous.view === 'home' || previous.view === 'conversations') ? previous.view : null;
|
|
291
|
+
}
|
|
269
292
|
if (next.unreadCount !== previous.unreadCount) {
|
|
270
293
|
this.events.emit(RepzoWidgetEvents.UnreadCountDidChange, {
|
|
271
294
|
count: next.unreadCount
|
|
@@ -808,7 +831,9 @@ export class WidgetStore {
|
|
|
808
831
|
this.threadGeneration += 1;
|
|
809
832
|
this.conversationCreation = null;
|
|
810
833
|
this.draftEvents = [];
|
|
834
|
+
this.failedActions.clear();
|
|
811
835
|
this.cancelMarkRead();
|
|
836
|
+
this.cancelServerCopySync();
|
|
812
837
|
}
|
|
813
838
|
requireBootConfig() {
|
|
814
839
|
if (!this.bootConfig) {
|
|
@@ -1124,7 +1149,10 @@ export class WidgetStore {
|
|
|
1124
1149
|
attachments: this.hydrateAttachments(data?.attachments, messageId),
|
|
1125
1150
|
metadata: this.hydrateMetadata(data?.metadata || null),
|
|
1126
1151
|
status: 'delivered',
|
|
1127
|
-
createdAt: data?.createdAt || new Date().toISOString()
|
|
1152
|
+
createdAt: data?.createdAt || new Date().toISOString(),
|
|
1153
|
+
...(typeof data?.clientMessageId === 'string' ? {
|
|
1154
|
+
clientMessageId: data.clientMessageId
|
|
1155
|
+
} : {})
|
|
1128
1156
|
};
|
|
1129
1157
|
const clearsTyping = message.direction === 'outbound' && message.senderType !== 'visitor';
|
|
1130
1158
|
if (clearsTyping) this.clearTypingTimer();
|
|
@@ -1155,10 +1183,11 @@ export class WidgetStore {
|
|
|
1155
1183
|
messageCount: conversation.messageCount + 1
|
|
1156
1184
|
} : conversation) : current.conversations;
|
|
1157
1185
|
if (onScreen) {
|
|
1158
|
-
const
|
|
1186
|
+
const shown = adoptOwnServerRows(current.messages, [message]);
|
|
1187
|
+
const alreadyPresent = shown.some(existing => existing.id === message.id);
|
|
1159
1188
|
return {
|
|
1160
1189
|
conversations,
|
|
1161
|
-
messages: alreadyPresent ? adoptServerAttachments(
|
|
1190
|
+
messages: alreadyPresent ? adoptServerAttachments(shown, [message]) : [...shown, message],
|
|
1162
1191
|
agentTyping: clearsTyping ? false : current.agentTyping
|
|
1163
1192
|
};
|
|
1164
1193
|
}
|
|
@@ -1397,6 +1426,14 @@ export class WidgetStore {
|
|
|
1397
1426
|
} : {})
|
|
1398
1427
|
});
|
|
1399
1428
|
}
|
|
1429
|
+
|
|
1430
|
+
/**
|
|
1431
|
+
* The thread's back button: the screen it was opened from, or the
|
|
1432
|
+
* conversation list when it was opened from outside the messenger.
|
|
1433
|
+
*/
|
|
1434
|
+
leaveConversation() {
|
|
1435
|
+
this.setView(this.chatOpenedFrom ?? (this.state.activeTab === 'messages' ? 'conversations' : 'home'));
|
|
1436
|
+
}
|
|
1400
1437
|
setActiveTab(tab) {
|
|
1401
1438
|
if (tab === 'home') {
|
|
1402
1439
|
this.setState({
|
|
@@ -1561,11 +1598,12 @@ export class WidgetStore {
|
|
|
1561
1598
|
this.setState(current => {
|
|
1562
1599
|
// Live replies and optimistic sends may have arrived since this page
|
|
1563
1600
|
// was requested. Keep them, preferring the live copy for shared ids.
|
|
1564
|
-
const liveIds = new Set(current.messages.map(message => message.id));
|
|
1565
1601
|
const page = this.hydrateMessages(result.data);
|
|
1602
|
+
const live = adoptOwnServerRows(current.messages, page);
|
|
1603
|
+
const liveIds = new Set(live.map(message => message.id));
|
|
1566
1604
|
const history = page.filter(message => !liveIds.has(message.id));
|
|
1567
1605
|
return {
|
|
1568
|
-
messages: sortByCreatedAt([...history, ...adoptServerAttachments(
|
|
1606
|
+
messages: sortByCreatedAt([...history, ...adoptServerAttachments(live, page)]),
|
|
1569
1607
|
messagesLoading: false,
|
|
1570
1608
|
hasMoreMessages: result.meta.hasMore,
|
|
1571
1609
|
messageCursor: result.meta.nextCursor
|
|
@@ -1633,14 +1671,15 @@ export class WidgetStore {
|
|
|
1633
1671
|
if (this.state.activeConversationId !== conversationId) return;
|
|
1634
1672
|
const fresh = this.hydrateMessages(result.data);
|
|
1635
1673
|
this.setState(current => {
|
|
1636
|
-
const
|
|
1674
|
+
const own = adoptOwnServerRows(current.messages, fresh);
|
|
1675
|
+
const known = new Set(own.map(message => message.id));
|
|
1637
1676
|
const missing = fresh.filter(message => !known.has(message.id));
|
|
1638
|
-
const upgraded = adoptServerStatuses(adoptServerDeletions(adoptServerAttachments(
|
|
1677
|
+
const upgraded = adoptServerStatuses(adoptServerDeletions(adoptServerAttachments(own, fresh), fresh), fresh);
|
|
1639
1678
|
if (missing.length === 0) return upgraded === current.messages ? {} : {
|
|
1640
1679
|
messages: upgraded
|
|
1641
1680
|
};
|
|
1642
|
-
const local =
|
|
1643
|
-
const hasServerMessages = local.length <
|
|
1681
|
+
const local = own.filter(isLocalMessage);
|
|
1682
|
+
const hasServerMessages = local.length < own.length;
|
|
1644
1683
|
// No overlap means the gap is longer than a page: restart from the
|
|
1645
1684
|
// newest page (keeping unsent bubbles) rather than leave a hole.
|
|
1646
1685
|
if (missing.length === fresh.length || !hasServerMessages) {
|
|
@@ -1665,6 +1704,40 @@ export class WidgetStore {
|
|
|
1665
1704
|
}
|
|
1666
1705
|
}
|
|
1667
1706
|
|
|
1707
|
+
/**
|
|
1708
|
+
* Until its stored copy replaces it, a sent bubble points at the visitor's
|
|
1709
|
+
* local files: a photo still opens in the viewer, but a PDF has nothing to
|
|
1710
|
+
* open. When the stream's echo hasn't brought that copy, re-read the
|
|
1711
|
+
* thread, and try again a few times while Workstation is still storing it.
|
|
1712
|
+
*/
|
|
1713
|
+
async syncServerCopies() {
|
|
1714
|
+
this.cancelServerCopySync();
|
|
1715
|
+
if (this.serverCopySyncing || !this.state.messages.some(isAwaitingServerCopy)) return;
|
|
1716
|
+
// A send still in flight would come back as a stored row beside its own
|
|
1717
|
+
// pending bubble. It syncs again when it settles.
|
|
1718
|
+
if (this.state.messages.some(message => isLocalMessage(message) && message.status === 'pending')) return;
|
|
1719
|
+
// Off screen: the thread reloads in full when it's opened again.
|
|
1720
|
+
const conversationId = this.state.activeConversationId;
|
|
1721
|
+
if (!conversationId || !this.isThreadOnScreen(conversationId)) return;
|
|
1722
|
+
const generation = this.threadGeneration;
|
|
1723
|
+
this.serverCopySyncing = true;
|
|
1724
|
+
try {
|
|
1725
|
+
// A no-op while a page load is in flight; the retry below covers it.
|
|
1726
|
+
await this.refreshOpenThread();
|
|
1727
|
+
} finally {
|
|
1728
|
+
this.serverCopySyncing = false;
|
|
1729
|
+
}
|
|
1730
|
+
if (this.threadGeneration !== generation || !this.state.messages.some(isAwaitingServerCopy)) return;
|
|
1731
|
+
const delay = SERVER_COPY_RETRY_MS[this.serverCopyRetries];
|
|
1732
|
+
if (delay === undefined) return;
|
|
1733
|
+
this.serverCopyRetries += 1;
|
|
1734
|
+
this.serverCopyTimer = setTimeout(() => void this.syncServerCopies(), delay);
|
|
1735
|
+
}
|
|
1736
|
+
cancelServerCopySync() {
|
|
1737
|
+
if (this.serverCopyTimer) clearTimeout(this.serverCopyTimer);
|
|
1738
|
+
this.serverCopyTimer = null;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1668
1741
|
/** A history page (newest-first) as the oldest-first list the UI renders. */
|
|
1669
1742
|
hydrateMessages(page) {
|
|
1670
1743
|
return page.map(message => ({
|
|
@@ -1712,9 +1785,11 @@ export class WidgetStore {
|
|
|
1712
1785
|
|
|
1713
1786
|
/**
|
|
1714
1787
|
* Rejects when the message wasn't sent, so the composer can keep
|
|
1715
|
-
* attachments for a retry. A text-only bubble is marked failed
|
|
1716
|
-
* attachments is removed instead — the
|
|
1717
|
-
* keeping both would leave a duplicate
|
|
1788
|
+
* attachments for a retry. A text-only bubble is marked failed and can be
|
|
1789
|
+
* re-sent with `retryMessage`; one with attachments is removed instead — the
|
|
1790
|
+
* composer's chips are the retry, and keeping both would leave a duplicate
|
|
1791
|
+
* after a successful resend. Likewise an agent UI action replaces the
|
|
1792
|
+
* failed bubble of the same action, since its card stays tappable.
|
|
1718
1793
|
*/
|
|
1719
1794
|
async sendMessage(body, attachments = [], agentUiAction) {
|
|
1720
1795
|
const trimmed = body.trim();
|
|
@@ -1722,7 +1797,6 @@ export class WidgetStore {
|
|
|
1722
1797
|
const {
|
|
1723
1798
|
visitorProfile
|
|
1724
1799
|
} = this.state;
|
|
1725
|
-
const generation = this.threadGeneration;
|
|
1726
1800
|
const tempId = `temp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1727
1801
|
const optimistic = {
|
|
1728
1802
|
id: tempId,
|
|
@@ -1746,20 +1820,58 @@ export class WidgetStore {
|
|
|
1746
1820
|
status: 'pending',
|
|
1747
1821
|
createdAt: new Date().toISOString()
|
|
1748
1822
|
};
|
|
1823
|
+
|
|
1824
|
+
// Tapping the card again is the retry: offering the old bubble's retry
|
|
1825
|
+
// too would run the action twice (two items in the cart, two bookings).
|
|
1826
|
+
const superseded = agentUiAction ? [...this.failedActions].filter(([, failed]) => failed.id === agentUiAction.id && failed.type === agentUiAction.type).map(([messageId]) => messageId) : [];
|
|
1827
|
+
superseded.forEach(messageId => this.failedActions.delete(messageId));
|
|
1749
1828
|
this.setState(current => ({
|
|
1750
|
-
messages: [...current.messages, optimistic],
|
|
1829
|
+
messages: [...current.messages.filter(message => !superseded.includes(message.id)), optimistic],
|
|
1751
1830
|
prefillText: ''
|
|
1752
1831
|
}));
|
|
1753
1832
|
this.track('widget_message_sent', {
|
|
1754
1833
|
hasText: !!trimmed,
|
|
1755
1834
|
attachmentCount: attachments.length
|
|
1756
1835
|
});
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1836
|
+
return this.deliverMessage(tempId, trimmed, attachments, agentUiAction);
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
/**
|
|
1840
|
+
* Re-sends a text bubble that failed. It moves to the end of the thread,
|
|
1841
|
+
* since it now goes out after anything sent meanwhile. Rejects like
|
|
1842
|
+
* `sendMessage` when it fails again; the bubble is then failed once more.
|
|
1843
|
+
*/
|
|
1844
|
+
async retryMessage(messageId) {
|
|
1845
|
+
const failed = this.state.messages.find(message => message.id === messageId && canRetryMessage(message));
|
|
1846
|
+
if (!failed) return;
|
|
1847
|
+
const agentUiAction = this.failedActions.get(messageId);
|
|
1848
|
+
this.failedActions.delete(messageId);
|
|
1849
|
+
const pending = {
|
|
1850
|
+
...failed,
|
|
1851
|
+
status: 'pending',
|
|
1852
|
+
createdAt: new Date().toISOString()
|
|
1853
|
+
};
|
|
1854
|
+
this.setState(current => ({
|
|
1855
|
+
messages: [...current.messages.filter(message => message.id !== messageId), pending]
|
|
1762
1856
|
}));
|
|
1857
|
+
return this.deliverMessage(messageId, failed.bodyPlain ?? '', [], agentUiAction);
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
/** Sends the optimistic bubble `tempId`, already on screen as pending. */
|
|
1861
|
+
async deliverMessage(tempId, trimmed, attachments, agentUiAction) {
|
|
1862
|
+
const {
|
|
1863
|
+
visitorProfile
|
|
1864
|
+
} = this.state;
|
|
1865
|
+
const generation = this.threadGeneration;
|
|
1866
|
+
const markFailed = () => {
|
|
1867
|
+
if (agentUiAction && attachments.length === 0) this.failedActions.set(tempId, agentUiAction);
|
|
1868
|
+
this.setState(current => ({
|
|
1869
|
+
messages: attachments.length > 0 ? current.messages.filter(message => message.id !== tempId) : current.messages.map(message => message.id === tempId ? {
|
|
1870
|
+
...message,
|
|
1871
|
+
status: 'failed'
|
|
1872
|
+
} : message)
|
|
1873
|
+
}));
|
|
1874
|
+
};
|
|
1763
1875
|
const epoch = this.epoch;
|
|
1764
1876
|
|
|
1765
1877
|
// A draft's first send creates the conversation. Anything sent from the
|
|
@@ -1785,24 +1897,41 @@ export class WidgetStore {
|
|
|
1785
1897
|
}
|
|
1786
1898
|
let createdId = null;
|
|
1787
1899
|
try {
|
|
1900
|
+
// Local files go up first; the message names each by its upload token.
|
|
1901
|
+
const sentAttachments = await Promise.all(attachments.map(async ({
|
|
1902
|
+
name,
|
|
1903
|
+
url,
|
|
1904
|
+
fileUri,
|
|
1905
|
+
contentType,
|
|
1906
|
+
size
|
|
1907
|
+
}) => fileUri ? {
|
|
1908
|
+
name,
|
|
1909
|
+
contentType,
|
|
1910
|
+
size,
|
|
1911
|
+
uploadToken: await this.client.uploadAttachment({
|
|
1912
|
+
uri: fileUri,
|
|
1913
|
+
name,
|
|
1914
|
+
contentType,
|
|
1915
|
+
size
|
|
1916
|
+
})
|
|
1917
|
+
} : {
|
|
1918
|
+
name,
|
|
1919
|
+
url,
|
|
1920
|
+
contentType,
|
|
1921
|
+
size
|
|
1922
|
+
}));
|
|
1923
|
+
if (this.isStale(epoch)) return;
|
|
1788
1924
|
const result = await this.client.sendMessage({
|
|
1789
1925
|
conversationId: conversationId || undefined,
|
|
1790
1926
|
forceNewConversation: !conversationId,
|
|
1791
1927
|
body: trimmed,
|
|
1792
|
-
attachments:
|
|
1793
|
-
name,
|
|
1794
|
-
url,
|
|
1795
|
-
contentType,
|
|
1796
|
-
size
|
|
1797
|
-
}) => ({
|
|
1798
|
-
name,
|
|
1799
|
-
url,
|
|
1800
|
-
contentType,
|
|
1801
|
-
size
|
|
1802
|
-
})) : undefined,
|
|
1928
|
+
attachments: sentAttachments.length > 0 ? sentAttachments : undefined,
|
|
1803
1929
|
visitorName: visitorProfile.name,
|
|
1804
1930
|
visitorEmail: visitorProfile.email,
|
|
1805
|
-
agentUiAction
|
|
1931
|
+
agentUiAction,
|
|
1932
|
+
// The bubble's id, the same on every retry: a newer Workstation stores
|
|
1933
|
+
// the message once even when an earlier attempt's response was lost.
|
|
1934
|
+
clientMessageId: tempId
|
|
1806
1935
|
});
|
|
1807
1936
|
if (this.isStale(epoch)) return;
|
|
1808
1937
|
if (ownCreation) createdId = result.conversationId;
|
|
@@ -1839,8 +1968,15 @@ export class WidgetStore {
|
|
|
1839
1968
|
} : {})
|
|
1840
1969
|
};
|
|
1841
1970
|
});
|
|
1971
|
+
|
|
1972
|
+
// New files to fetch the stored copy of: a fresh round of re-reads.
|
|
1973
|
+
const sent = this.state.messages.find(message => message.id === result.messageId);
|
|
1974
|
+
if (sent && hasLocalAttachments(sent)) this.serverCopyRetries = 0;
|
|
1842
1975
|
} catch (error) {
|
|
1843
1976
|
if (this.isStale(epoch)) throw error;
|
|
1977
|
+
// The response was lost, but its stored row has already taken the
|
|
1978
|
+
// bubble's place (newer Workstation deployments): it was sent.
|
|
1979
|
+
if (this.state.messages.some(message => message.clientMessageId === tempId)) return;
|
|
1844
1980
|
markFailed();
|
|
1845
1981
|
throw error;
|
|
1846
1982
|
} finally {
|
|
@@ -1851,6 +1987,8 @@ export class WidgetStore {
|
|
|
1851
1987
|
// Anything held back for the draft now has a home (or goes to unread).
|
|
1852
1988
|
this.replayDraftEvents();
|
|
1853
1989
|
}
|
|
1990
|
+
// Waits for every send to settle, so this runs when the last one does.
|
|
1991
|
+
if (!this.isStale(epoch)) void this.syncServerCopies();
|
|
1854
1992
|
}
|
|
1855
1993
|
}
|
|
1856
1994
|
async identifyVisitor(data) {
|
|
@@ -2324,11 +2462,6 @@ function createConversationCreation(generation) {
|
|
|
2324
2462
|
};
|
|
2325
2463
|
}
|
|
2326
2464
|
|
|
2327
|
-
/** Optimistic visitor bubbles the server hasn't acknowledged (pending/failed). */
|
|
2328
|
-
function isLocalMessage(message) {
|
|
2329
|
-
return message.id.startsWith('temp-');
|
|
2330
|
-
}
|
|
2331
|
-
|
|
2332
2465
|
/** A "Seen" missed while the stream was down shows up on the next re-read. */
|
|
2333
2466
|
function adoptServerStatuses(messages, server) {
|
|
2334
2467
|
const seen = new Set(server.filter(message => message.status === 'read').map(message => message.id));
|
|
@@ -2382,17 +2515,82 @@ function adoptServerAttachments(messages, server) {
|
|
|
2382
2515
|
let changed = false;
|
|
2383
2516
|
const next = messages.map(message => {
|
|
2384
2517
|
const copy = serverCopies.get(message.id);
|
|
2385
|
-
|
|
2386
|
-
|
|
2518
|
+
if (!copy || !hasLocalAttachments(message)) return message;
|
|
2519
|
+
const attachments = mergeAttachments(message.attachments, copy.attachments);
|
|
2520
|
+
if (attachments === message.attachments) return message;
|
|
2387
2521
|
changed = true;
|
|
2388
2522
|
return {
|
|
2389
2523
|
...message,
|
|
2390
|
-
attachments
|
|
2524
|
+
attachments
|
|
2391
2525
|
};
|
|
2392
2526
|
});
|
|
2393
2527
|
return changed ? next : messages;
|
|
2394
2528
|
}
|
|
2395
2529
|
|
|
2530
|
+
/**
|
|
2531
|
+
* Each attachment's server copy, except one without a usable URL: that would
|
|
2532
|
+
* only blank its local preview, so the preview stays until a later copy has it.
|
|
2533
|
+
* Returns `local` itself when nothing changes.
|
|
2534
|
+
*/
|
|
2535
|
+
function mergeAttachments(local, server) {
|
|
2536
|
+
// Without a one-to-one match, only a fully usable server copy is safe to take.
|
|
2537
|
+
if (local.length !== server.length) {
|
|
2538
|
+
return server.every(attachment => attachment.url) ? server : local;
|
|
2539
|
+
}
|
|
2540
|
+
const merged = server.map((attachment, index) => {
|
|
2541
|
+
const own = local[index];
|
|
2542
|
+
return isLocalAttachment(own) && attachment.url ? attachment : own;
|
|
2543
|
+
});
|
|
2544
|
+
return merged.every((attachment, index) => attachment === local[index]) ? local : merged;
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
/**
|
|
2548
|
+
* Newer Workstation deployments echo `clientMessageId` on the visitor's own
|
|
2549
|
+
* rows. Such a row replaces the pending or failed bubble it came from instead
|
|
2550
|
+
* of showing beside it: no duplicate while a send is in flight, and no retry
|
|
2551
|
+
* for a message that was stored although its response was lost.
|
|
2552
|
+
*/
|
|
2553
|
+
function adoptOwnServerRows(messages, server) {
|
|
2554
|
+
const rows = new Map();
|
|
2555
|
+
server.forEach(row => {
|
|
2556
|
+
if (typeof row.clientMessageId === 'string' && row.clientMessageId) rows.set(row.clientMessageId, row);
|
|
2557
|
+
});
|
|
2558
|
+
if (rows.size === 0) return messages;
|
|
2559
|
+
const shown = new Set(messages.map(message => message.id));
|
|
2560
|
+
let changed = false;
|
|
2561
|
+
const next = [];
|
|
2562
|
+
messages.forEach(message => {
|
|
2563
|
+
const row = isLocalMessage(message) ? rows.get(message.id) : undefined;
|
|
2564
|
+
if (!row) {
|
|
2565
|
+
next.push(message);
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2568
|
+
changed = true;
|
|
2569
|
+
// Already on screen under its server id (an earlier echo): drop the bubble.
|
|
2570
|
+
if (shown.has(row.id)) return;
|
|
2571
|
+
next.push({
|
|
2572
|
+
...row,
|
|
2573
|
+
attachments: row.attachments && message.attachments ? mergeAttachments(message.attachments, row.attachments) : row.attachments ?? message.attachments
|
|
2574
|
+
});
|
|
2575
|
+
});
|
|
2576
|
+
return changed ? next : messages;
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
/** Still the picked file (a `file://` URI, or no URL at all), not the uploaded copy. */
|
|
2580
|
+
function isLocalAttachment(attachment) {
|
|
2581
|
+
return !/^https?:\/\//i.test(attachment.url);
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
/** A visitor bubble still showing the picked files rather than the uploaded copies. */
|
|
2585
|
+
function hasLocalAttachments(message) {
|
|
2586
|
+
return !!message.attachments?.some(isLocalAttachment);
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
/** A sent bubble whose stored copy hasn't replaced the picked files yet. */
|
|
2590
|
+
function isAwaitingServerCopy(message) {
|
|
2591
|
+
return !isLocalMessage(message) && hasLocalAttachments(message);
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2396
2594
|
/** Stable, so same-instant rows keep their order; bad dates sort as oldest. */
|
|
2397
2595
|
function sortByCreatedAt(messages) {
|
|
2398
2596
|
const time = message => Date.parse(message.createdAt) || 0;
|