@repzo/widget-react-native 1.3.0 → 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 +47 -0
- package/README.md +8 -4
- 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/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 +216 -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 +7 -3
- package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
- package/lib/commonjs/ui/components/ImageViewer.js +3 -2
- package/lib/commonjs/ui/components/ImageViewer.js.map +1 -1
- package/lib/commonjs/ui/components/icons.js +22 -16
- package/lib/commonjs/ui/components/icons.js.map +1 -1
- package/lib/commonjs/ui/screens/chat/ChatView.js +36 -14
- package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js +12 -2
- 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/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 +216 -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 +7 -3
- package/lib/module/ui/WidgetProvider.js.map +1 -1
- package/lib/module/ui/components/ImageViewer.js +3 -2
- package/lib/module/ui/components/ImageViewer.js.map +1 -1
- package/lib/module/ui/components/icons.js +21 -15
- package/lib/module/ui/components/icons.js.map +1 -1
- package/lib/module/ui/screens/chat/ChatView.js +38 -16
- package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/module/ui/screens/chat/ChatView.styles.js +12 -2
- 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/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 +27 -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.map +1 -1
- package/lib/typescript/commonjs/ui/components/icons.d.ts +2 -1
- package/lib/typescript/commonjs/ui/components/icons.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/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 +10 -2
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +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/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 +27 -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.map +1 -1
- package/lib/typescript/module/ui/components/icons.d.ts +2 -1
- package/lib/typescript/module/ui/components/icons.d.ts.map +1 -1
- package/lib/typescript/module/ui/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 +10 -2
- package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +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 +1 -1
- package/src/api/client.ts +66 -2
- package/src/attachments/pickers.ts +87 -25
- package/src/i18n.ts +2 -0
- package/src/locales.ts +1 -0
- package/src/state/widget-store.ts +210 -33
- package/src/types.ts +17 -0
- package/src/ui/WidgetProvider.tsx +15 -4
- package/src/ui/components/ImageViewer.tsx +10 -6
- package/src/ui/components/icons.tsx +10 -12
- package/src/ui/screens/chat/ChatView.styles.ts +12 -2
- package/src/ui/screens/chat/ChatView.tsx +45 -22
- 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
|
@@ -13,6 +13,7 @@ var _pushNotifications = require("../push/push-notifications.js");
|
|
|
13
13
|
var _storage = require("../storage.js");
|
|
14
14
|
var _pickers = require("../attachments/pickers.js");
|
|
15
15
|
var _types = require("../types.js");
|
|
16
|
+
var _conversations = require("../ui/utils/conversations.js");
|
|
16
17
|
var _safeUrl = require("../ui/utils/safe-url.js");
|
|
17
18
|
/**
|
|
18
19
|
* The widget's single source of truth.
|
|
@@ -25,7 +26,7 @@ var _safeUrl = require("../ui/utils/safe-url.js");
|
|
|
25
26
|
*/
|
|
26
27
|
|
|
27
28
|
/** Sent on every init; must match `package.json` (tests/version.test.js checks). */
|
|
28
|
-
const SDK_VERSION = '@repzo/widget-react-native@1.3.
|
|
29
|
+
const SDK_VERSION = '@repzo/widget-react-native@1.3.1';
|
|
29
30
|
|
|
30
31
|
/** The platform this session reports — mobile sessions get the Mobile SDKs overrides. */
|
|
31
32
|
function sessionPlatform() {
|
|
@@ -55,6 +56,8 @@ const MARK_READ_DEBOUNCE_MS = 1_000;
|
|
|
55
56
|
const FOREGROUND_REFRESH_AFTER_MS = 5 * 60_000;
|
|
56
57
|
/** Live events held back while a draft's first send is creating its thread. */
|
|
57
58
|
const DRAFT_EVENT_BUFFER_LIMIT = 50;
|
|
59
|
+
/** Waits between re-reads for a sent file's stored copy while Workstation is still storing it. */
|
|
60
|
+
const SERVER_COPY_RETRY_MS = [2_000, 5_000, 15_000];
|
|
58
61
|
/** How long a post stays up after a reaction, thanking them, before it goes. */
|
|
59
62
|
const POST_THANKS_MS = exports.POST_THANKS_MS = 1600;
|
|
60
63
|
|
|
@@ -187,6 +190,12 @@ class WidgetStore {
|
|
|
187
190
|
seenMessageIds = [];
|
|
188
191
|
/** Statuses (e.g. "read") that arrived for an id before a send response revealed it. */
|
|
189
192
|
earlyStatuses = new Map();
|
|
193
|
+
/** The agent UI action behind a failed bubble, so Retry runs it again too. */
|
|
194
|
+
failedActions = new Map();
|
|
195
|
+
/** The next re-read for a sent file's stored copy (see `syncServerCopies`). */
|
|
196
|
+
serverCopyTimer = null;
|
|
197
|
+
serverCopyRetries = 0;
|
|
198
|
+
serverCopySyncing = false;
|
|
190
199
|
|
|
191
200
|
/**
|
|
192
201
|
* Identity generation. Bumped whenever boot/login/logout is requested; every
|
|
@@ -825,7 +834,9 @@ class WidgetStore {
|
|
|
825
834
|
this.threadGeneration += 1;
|
|
826
835
|
this.conversationCreation = null;
|
|
827
836
|
this.draftEvents = [];
|
|
837
|
+
this.failedActions.clear();
|
|
828
838
|
this.cancelMarkRead();
|
|
839
|
+
this.cancelServerCopySync();
|
|
829
840
|
}
|
|
830
841
|
requireBootConfig() {
|
|
831
842
|
if (!this.bootConfig) {
|
|
@@ -1141,7 +1152,10 @@ class WidgetStore {
|
|
|
1141
1152
|
attachments: this.hydrateAttachments(data?.attachments, messageId),
|
|
1142
1153
|
metadata: this.hydrateMetadata(data?.metadata || null),
|
|
1143
1154
|
status: 'delivered',
|
|
1144
|
-
createdAt: data?.createdAt || new Date().toISOString()
|
|
1155
|
+
createdAt: data?.createdAt || new Date().toISOString(),
|
|
1156
|
+
...(typeof data?.clientMessageId === 'string' ? {
|
|
1157
|
+
clientMessageId: data.clientMessageId
|
|
1158
|
+
} : {})
|
|
1145
1159
|
};
|
|
1146
1160
|
const clearsTyping = message.direction === 'outbound' && message.senderType !== 'visitor';
|
|
1147
1161
|
if (clearsTyping) this.clearTypingTimer();
|
|
@@ -1172,10 +1186,11 @@ class WidgetStore {
|
|
|
1172
1186
|
messageCount: conversation.messageCount + 1
|
|
1173
1187
|
} : conversation) : current.conversations;
|
|
1174
1188
|
if (onScreen) {
|
|
1175
|
-
const
|
|
1189
|
+
const shown = adoptOwnServerRows(current.messages, [message]);
|
|
1190
|
+
const alreadyPresent = shown.some(existing => existing.id === message.id);
|
|
1176
1191
|
return {
|
|
1177
1192
|
conversations,
|
|
1178
|
-
messages: alreadyPresent ? adoptServerAttachments(
|
|
1193
|
+
messages: alreadyPresent ? adoptServerAttachments(shown, [message]) : [...shown, message],
|
|
1179
1194
|
agentTyping: clearsTyping ? false : current.agentTyping
|
|
1180
1195
|
};
|
|
1181
1196
|
}
|
|
@@ -1586,11 +1601,12 @@ class WidgetStore {
|
|
|
1586
1601
|
this.setState(current => {
|
|
1587
1602
|
// Live replies and optimistic sends may have arrived since this page
|
|
1588
1603
|
// was requested. Keep them, preferring the live copy for shared ids.
|
|
1589
|
-
const liveIds = new Set(current.messages.map(message => message.id));
|
|
1590
1604
|
const page = this.hydrateMessages(result.data);
|
|
1605
|
+
const live = adoptOwnServerRows(current.messages, page);
|
|
1606
|
+
const liveIds = new Set(live.map(message => message.id));
|
|
1591
1607
|
const history = page.filter(message => !liveIds.has(message.id));
|
|
1592
1608
|
return {
|
|
1593
|
-
messages: sortByCreatedAt([...history, ...adoptServerAttachments(
|
|
1609
|
+
messages: sortByCreatedAt([...history, ...adoptServerAttachments(live, page)]),
|
|
1594
1610
|
messagesLoading: false,
|
|
1595
1611
|
hasMoreMessages: result.meta.hasMore,
|
|
1596
1612
|
messageCursor: result.meta.nextCursor
|
|
@@ -1658,14 +1674,15 @@ class WidgetStore {
|
|
|
1658
1674
|
if (this.state.activeConversationId !== conversationId) return;
|
|
1659
1675
|
const fresh = this.hydrateMessages(result.data);
|
|
1660
1676
|
this.setState(current => {
|
|
1661
|
-
const
|
|
1677
|
+
const own = adoptOwnServerRows(current.messages, fresh);
|
|
1678
|
+
const known = new Set(own.map(message => message.id));
|
|
1662
1679
|
const missing = fresh.filter(message => !known.has(message.id));
|
|
1663
|
-
const upgraded = adoptServerStatuses(adoptServerDeletions(adoptServerAttachments(
|
|
1680
|
+
const upgraded = adoptServerStatuses(adoptServerDeletions(adoptServerAttachments(own, fresh), fresh), fresh);
|
|
1664
1681
|
if (missing.length === 0) return upgraded === current.messages ? {} : {
|
|
1665
1682
|
messages: upgraded
|
|
1666
1683
|
};
|
|
1667
|
-
const local =
|
|
1668
|
-
const hasServerMessages = local.length <
|
|
1684
|
+
const local = own.filter(_conversations.isLocalMessage);
|
|
1685
|
+
const hasServerMessages = local.length < own.length;
|
|
1669
1686
|
// No overlap means the gap is longer than a page: restart from the
|
|
1670
1687
|
// newest page (keeping unsent bubbles) rather than leave a hole.
|
|
1671
1688
|
if (missing.length === fresh.length || !hasServerMessages) {
|
|
@@ -1690,6 +1707,40 @@ class WidgetStore {
|
|
|
1690
1707
|
}
|
|
1691
1708
|
}
|
|
1692
1709
|
|
|
1710
|
+
/**
|
|
1711
|
+
* Until its stored copy replaces it, a sent bubble points at the visitor's
|
|
1712
|
+
* local files: a photo still opens in the viewer, but a PDF has nothing to
|
|
1713
|
+
* open. When the stream's echo hasn't brought that copy, re-read the
|
|
1714
|
+
* thread, and try again a few times while Workstation is still storing it.
|
|
1715
|
+
*/
|
|
1716
|
+
async syncServerCopies() {
|
|
1717
|
+
this.cancelServerCopySync();
|
|
1718
|
+
if (this.serverCopySyncing || !this.state.messages.some(isAwaitingServerCopy)) return;
|
|
1719
|
+
// A send still in flight would come back as a stored row beside its own
|
|
1720
|
+
// pending bubble. It syncs again when it settles.
|
|
1721
|
+
if (this.state.messages.some(message => (0, _conversations.isLocalMessage)(message) && message.status === 'pending')) return;
|
|
1722
|
+
// Off screen: the thread reloads in full when it's opened again.
|
|
1723
|
+
const conversationId = this.state.activeConversationId;
|
|
1724
|
+
if (!conversationId || !this.isThreadOnScreen(conversationId)) return;
|
|
1725
|
+
const generation = this.threadGeneration;
|
|
1726
|
+
this.serverCopySyncing = true;
|
|
1727
|
+
try {
|
|
1728
|
+
// A no-op while a page load is in flight; the retry below covers it.
|
|
1729
|
+
await this.refreshOpenThread();
|
|
1730
|
+
} finally {
|
|
1731
|
+
this.serverCopySyncing = false;
|
|
1732
|
+
}
|
|
1733
|
+
if (this.threadGeneration !== generation || !this.state.messages.some(isAwaitingServerCopy)) return;
|
|
1734
|
+
const delay = SERVER_COPY_RETRY_MS[this.serverCopyRetries];
|
|
1735
|
+
if (delay === undefined) return;
|
|
1736
|
+
this.serverCopyRetries += 1;
|
|
1737
|
+
this.serverCopyTimer = setTimeout(() => void this.syncServerCopies(), delay);
|
|
1738
|
+
}
|
|
1739
|
+
cancelServerCopySync() {
|
|
1740
|
+
if (this.serverCopyTimer) clearTimeout(this.serverCopyTimer);
|
|
1741
|
+
this.serverCopyTimer = null;
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1693
1744
|
/** A history page (newest-first) as the oldest-first list the UI renders. */
|
|
1694
1745
|
hydrateMessages(page) {
|
|
1695
1746
|
return page.map(message => ({
|
|
@@ -1737,9 +1788,11 @@ class WidgetStore {
|
|
|
1737
1788
|
|
|
1738
1789
|
/**
|
|
1739
1790
|
* Rejects when the message wasn't sent, so the composer can keep
|
|
1740
|
-
* attachments for a retry. A text-only bubble is marked failed
|
|
1741
|
-
* attachments is removed instead — the
|
|
1742
|
-
* keeping both would leave a duplicate
|
|
1791
|
+
* attachments for a retry. A text-only bubble is marked failed and can be
|
|
1792
|
+
* re-sent with `retryMessage`; one with attachments is removed instead — the
|
|
1793
|
+
* composer's chips are the retry, and keeping both would leave a duplicate
|
|
1794
|
+
* after a successful resend. Likewise an agent UI action replaces the
|
|
1795
|
+
* failed bubble of the same action, since its card stays tappable.
|
|
1743
1796
|
*/
|
|
1744
1797
|
async sendMessage(body, attachments = [], agentUiAction) {
|
|
1745
1798
|
const trimmed = body.trim();
|
|
@@ -1747,7 +1800,6 @@ class WidgetStore {
|
|
|
1747
1800
|
const {
|
|
1748
1801
|
visitorProfile
|
|
1749
1802
|
} = this.state;
|
|
1750
|
-
const generation = this.threadGeneration;
|
|
1751
1803
|
const tempId = `temp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1752
1804
|
const optimistic = {
|
|
1753
1805
|
id: tempId,
|
|
@@ -1771,20 +1823,58 @@ class WidgetStore {
|
|
|
1771
1823
|
status: 'pending',
|
|
1772
1824
|
createdAt: new Date().toISOString()
|
|
1773
1825
|
};
|
|
1826
|
+
|
|
1827
|
+
// Tapping the card again is the retry: offering the old bubble's retry
|
|
1828
|
+
// too would run the action twice (two items in the cart, two bookings).
|
|
1829
|
+
const superseded = agentUiAction ? [...this.failedActions].filter(([, failed]) => failed.id === agentUiAction.id && failed.type === agentUiAction.type).map(([messageId]) => messageId) : [];
|
|
1830
|
+
superseded.forEach(messageId => this.failedActions.delete(messageId));
|
|
1774
1831
|
this.setState(current => ({
|
|
1775
|
-
messages: [...current.messages, optimistic],
|
|
1832
|
+
messages: [...current.messages.filter(message => !superseded.includes(message.id)), optimistic],
|
|
1776
1833
|
prefillText: ''
|
|
1777
1834
|
}));
|
|
1778
1835
|
this.track('widget_message_sent', {
|
|
1779
1836
|
hasText: !!trimmed,
|
|
1780
1837
|
attachmentCount: attachments.length
|
|
1781
1838
|
});
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1839
|
+
return this.deliverMessage(tempId, trimmed, attachments, agentUiAction);
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
/**
|
|
1843
|
+
* Re-sends a text bubble that failed. It moves to the end of the thread,
|
|
1844
|
+
* since it now goes out after anything sent meanwhile. Rejects like
|
|
1845
|
+
* `sendMessage` when it fails again; the bubble is then failed once more.
|
|
1846
|
+
*/
|
|
1847
|
+
async retryMessage(messageId) {
|
|
1848
|
+
const failed = this.state.messages.find(message => message.id === messageId && (0, _conversations.canRetryMessage)(message));
|
|
1849
|
+
if (!failed) return;
|
|
1850
|
+
const agentUiAction = this.failedActions.get(messageId);
|
|
1851
|
+
this.failedActions.delete(messageId);
|
|
1852
|
+
const pending = {
|
|
1853
|
+
...failed,
|
|
1854
|
+
status: 'pending',
|
|
1855
|
+
createdAt: new Date().toISOString()
|
|
1856
|
+
};
|
|
1857
|
+
this.setState(current => ({
|
|
1858
|
+
messages: [...current.messages.filter(message => message.id !== messageId), pending]
|
|
1787
1859
|
}));
|
|
1860
|
+
return this.deliverMessage(messageId, failed.bodyPlain ?? '', [], agentUiAction);
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
/** Sends the optimistic bubble `tempId`, already on screen as pending. */
|
|
1864
|
+
async deliverMessage(tempId, trimmed, attachments, agentUiAction) {
|
|
1865
|
+
const {
|
|
1866
|
+
visitorProfile
|
|
1867
|
+
} = this.state;
|
|
1868
|
+
const generation = this.threadGeneration;
|
|
1869
|
+
const markFailed = () => {
|
|
1870
|
+
if (agentUiAction && attachments.length === 0) this.failedActions.set(tempId, agentUiAction);
|
|
1871
|
+
this.setState(current => ({
|
|
1872
|
+
messages: attachments.length > 0 ? current.messages.filter(message => message.id !== tempId) : current.messages.map(message => message.id === tempId ? {
|
|
1873
|
+
...message,
|
|
1874
|
+
status: 'failed'
|
|
1875
|
+
} : message)
|
|
1876
|
+
}));
|
|
1877
|
+
};
|
|
1788
1878
|
const epoch = this.epoch;
|
|
1789
1879
|
|
|
1790
1880
|
// A draft's first send creates the conversation. Anything sent from the
|
|
@@ -1810,24 +1900,41 @@ class WidgetStore {
|
|
|
1810
1900
|
}
|
|
1811
1901
|
let createdId = null;
|
|
1812
1902
|
try {
|
|
1903
|
+
// Local files go up first; the message names each by its upload token.
|
|
1904
|
+
const sentAttachments = await Promise.all(attachments.map(async ({
|
|
1905
|
+
name,
|
|
1906
|
+
url,
|
|
1907
|
+
fileUri,
|
|
1908
|
+
contentType,
|
|
1909
|
+
size
|
|
1910
|
+
}) => fileUri ? {
|
|
1911
|
+
name,
|
|
1912
|
+
contentType,
|
|
1913
|
+
size,
|
|
1914
|
+
uploadToken: await this.client.uploadAttachment({
|
|
1915
|
+
uri: fileUri,
|
|
1916
|
+
name,
|
|
1917
|
+
contentType,
|
|
1918
|
+
size
|
|
1919
|
+
})
|
|
1920
|
+
} : {
|
|
1921
|
+
name,
|
|
1922
|
+
url,
|
|
1923
|
+
contentType,
|
|
1924
|
+
size
|
|
1925
|
+
}));
|
|
1926
|
+
if (this.isStale(epoch)) return;
|
|
1813
1927
|
const result = await this.client.sendMessage({
|
|
1814
1928
|
conversationId: conversationId || undefined,
|
|
1815
1929
|
forceNewConversation: !conversationId,
|
|
1816
1930
|
body: trimmed,
|
|
1817
|
-
attachments:
|
|
1818
|
-
name,
|
|
1819
|
-
url,
|
|
1820
|
-
contentType,
|
|
1821
|
-
size
|
|
1822
|
-
}) => ({
|
|
1823
|
-
name,
|
|
1824
|
-
url,
|
|
1825
|
-
contentType,
|
|
1826
|
-
size
|
|
1827
|
-
})) : undefined,
|
|
1931
|
+
attachments: sentAttachments.length > 0 ? sentAttachments : undefined,
|
|
1828
1932
|
visitorName: visitorProfile.name,
|
|
1829
1933
|
visitorEmail: visitorProfile.email,
|
|
1830
|
-
agentUiAction
|
|
1934
|
+
agentUiAction,
|
|
1935
|
+
// The bubble's id, the same on every retry: a newer Workstation stores
|
|
1936
|
+
// the message once even when an earlier attempt's response was lost.
|
|
1937
|
+
clientMessageId: tempId
|
|
1831
1938
|
});
|
|
1832
1939
|
if (this.isStale(epoch)) return;
|
|
1833
1940
|
if (ownCreation) createdId = result.conversationId;
|
|
@@ -1864,8 +1971,15 @@ class WidgetStore {
|
|
|
1864
1971
|
} : {})
|
|
1865
1972
|
};
|
|
1866
1973
|
});
|
|
1974
|
+
|
|
1975
|
+
// New files to fetch the stored copy of: a fresh round of re-reads.
|
|
1976
|
+
const sent = this.state.messages.find(message => message.id === result.messageId);
|
|
1977
|
+
if (sent && hasLocalAttachments(sent)) this.serverCopyRetries = 0;
|
|
1867
1978
|
} catch (error) {
|
|
1868
1979
|
if (this.isStale(epoch)) throw error;
|
|
1980
|
+
// The response was lost, but its stored row has already taken the
|
|
1981
|
+
// bubble's place (newer Workstation deployments): it was sent.
|
|
1982
|
+
if (this.state.messages.some(message => message.clientMessageId === tempId)) return;
|
|
1869
1983
|
markFailed();
|
|
1870
1984
|
throw error;
|
|
1871
1985
|
} finally {
|
|
@@ -1876,6 +1990,8 @@ class WidgetStore {
|
|
|
1876
1990
|
// Anything held back for the draft now has a home (or goes to unread).
|
|
1877
1991
|
this.replayDraftEvents();
|
|
1878
1992
|
}
|
|
1993
|
+
// Waits for every send to settle, so this runs when the last one does.
|
|
1994
|
+
if (!this.isStale(epoch)) void this.syncServerCopies();
|
|
1879
1995
|
}
|
|
1880
1996
|
}
|
|
1881
1997
|
async identifyVisitor(data) {
|
|
@@ -2350,11 +2466,6 @@ function createConversationCreation(generation) {
|
|
|
2350
2466
|
};
|
|
2351
2467
|
}
|
|
2352
2468
|
|
|
2353
|
-
/** Optimistic visitor bubbles the server hasn't acknowledged (pending/failed). */
|
|
2354
|
-
function isLocalMessage(message) {
|
|
2355
|
-
return message.id.startsWith('temp-');
|
|
2356
|
-
}
|
|
2357
|
-
|
|
2358
2469
|
/** A "Seen" missed while the stream was down shows up on the next re-read. */
|
|
2359
2470
|
function adoptServerStatuses(messages, server) {
|
|
2360
2471
|
const seen = new Set(server.filter(message => message.status === 'read').map(message => message.id));
|
|
@@ -2408,17 +2519,82 @@ function adoptServerAttachments(messages, server) {
|
|
|
2408
2519
|
let changed = false;
|
|
2409
2520
|
const next = messages.map(message => {
|
|
2410
2521
|
const copy = serverCopies.get(message.id);
|
|
2411
|
-
|
|
2412
|
-
|
|
2522
|
+
if (!copy || !hasLocalAttachments(message)) return message;
|
|
2523
|
+
const attachments = mergeAttachments(message.attachments, copy.attachments);
|
|
2524
|
+
if (attachments === message.attachments) return message;
|
|
2413
2525
|
changed = true;
|
|
2414
2526
|
return {
|
|
2415
2527
|
...message,
|
|
2416
|
-
attachments
|
|
2528
|
+
attachments
|
|
2417
2529
|
};
|
|
2418
2530
|
});
|
|
2419
2531
|
return changed ? next : messages;
|
|
2420
2532
|
}
|
|
2421
2533
|
|
|
2534
|
+
/**
|
|
2535
|
+
* Each attachment's server copy, except one without a usable URL: that would
|
|
2536
|
+
* only blank its local preview, so the preview stays until a later copy has it.
|
|
2537
|
+
* Returns `local` itself when nothing changes.
|
|
2538
|
+
*/
|
|
2539
|
+
function mergeAttachments(local, server) {
|
|
2540
|
+
// Without a one-to-one match, only a fully usable server copy is safe to take.
|
|
2541
|
+
if (local.length !== server.length) {
|
|
2542
|
+
return server.every(attachment => attachment.url) ? server : local;
|
|
2543
|
+
}
|
|
2544
|
+
const merged = server.map((attachment, index) => {
|
|
2545
|
+
const own = local[index];
|
|
2546
|
+
return isLocalAttachment(own) && attachment.url ? attachment : own;
|
|
2547
|
+
});
|
|
2548
|
+
return merged.every((attachment, index) => attachment === local[index]) ? local : merged;
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
/**
|
|
2552
|
+
* Newer Workstation deployments echo `clientMessageId` on the visitor's own
|
|
2553
|
+
* rows. Such a row replaces the pending or failed bubble it came from instead
|
|
2554
|
+
* of showing beside it: no duplicate while a send is in flight, and no retry
|
|
2555
|
+
* for a message that was stored although its response was lost.
|
|
2556
|
+
*/
|
|
2557
|
+
function adoptOwnServerRows(messages, server) {
|
|
2558
|
+
const rows = new Map();
|
|
2559
|
+
server.forEach(row => {
|
|
2560
|
+
if (typeof row.clientMessageId === 'string' && row.clientMessageId) rows.set(row.clientMessageId, row);
|
|
2561
|
+
});
|
|
2562
|
+
if (rows.size === 0) return messages;
|
|
2563
|
+
const shown = new Set(messages.map(message => message.id));
|
|
2564
|
+
let changed = false;
|
|
2565
|
+
const next = [];
|
|
2566
|
+
messages.forEach(message => {
|
|
2567
|
+
const row = (0, _conversations.isLocalMessage)(message) ? rows.get(message.id) : undefined;
|
|
2568
|
+
if (!row) {
|
|
2569
|
+
next.push(message);
|
|
2570
|
+
return;
|
|
2571
|
+
}
|
|
2572
|
+
changed = true;
|
|
2573
|
+
// Already on screen under its server id (an earlier echo): drop the bubble.
|
|
2574
|
+
if (shown.has(row.id)) return;
|
|
2575
|
+
next.push({
|
|
2576
|
+
...row,
|
|
2577
|
+
attachments: row.attachments && message.attachments ? mergeAttachments(message.attachments, row.attachments) : row.attachments ?? message.attachments
|
|
2578
|
+
});
|
|
2579
|
+
});
|
|
2580
|
+
return changed ? next : messages;
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
/** Still the picked file (a `file://` URI, or no URL at all), not the uploaded copy. */
|
|
2584
|
+
function isLocalAttachment(attachment) {
|
|
2585
|
+
return !/^https?:\/\//i.test(attachment.url);
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
/** A visitor bubble still showing the picked files rather than the uploaded copies. */
|
|
2589
|
+
function hasLocalAttachments(message) {
|
|
2590
|
+
return !!message.attachments?.some(isLocalAttachment);
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
/** A sent bubble whose stored copy hasn't replaced the picked files yet. */
|
|
2594
|
+
function isAwaitingServerCopy(message) {
|
|
2595
|
+
return !(0, _conversations.isLocalMessage)(message) && hasLocalAttachments(message);
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2422
2598
|
/** Stable, so same-instant rows keep their order; bad dates sort as oldest. */
|
|
2423
2599
|
function sortByCreatedAt(messages) {
|
|
2424
2600
|
const time = message => Date.parse(message.createdAt) || 0;
|