@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
|
@@ -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.3.
|
|
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
|
|
|
@@ -184,6 +187,12 @@ export class WidgetStore {
|
|
|
184
187
|
seenMessageIds = [];
|
|
185
188
|
/** Statuses (e.g. "read") that arrived for an id before a send response revealed it. */
|
|
186
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;
|
|
187
196
|
|
|
188
197
|
/**
|
|
189
198
|
* Identity generation. Bumped whenever boot/login/logout is requested; every
|
|
@@ -822,7 +831,9 @@ export class WidgetStore {
|
|
|
822
831
|
this.threadGeneration += 1;
|
|
823
832
|
this.conversationCreation = null;
|
|
824
833
|
this.draftEvents = [];
|
|
834
|
+
this.failedActions.clear();
|
|
825
835
|
this.cancelMarkRead();
|
|
836
|
+
this.cancelServerCopySync();
|
|
826
837
|
}
|
|
827
838
|
requireBootConfig() {
|
|
828
839
|
if (!this.bootConfig) {
|
|
@@ -1138,7 +1149,10 @@ export class WidgetStore {
|
|
|
1138
1149
|
attachments: this.hydrateAttachments(data?.attachments, messageId),
|
|
1139
1150
|
metadata: this.hydrateMetadata(data?.metadata || null),
|
|
1140
1151
|
status: 'delivered',
|
|
1141
|
-
createdAt: data?.createdAt || new Date().toISOString()
|
|
1152
|
+
createdAt: data?.createdAt || new Date().toISOString(),
|
|
1153
|
+
...(typeof data?.clientMessageId === 'string' ? {
|
|
1154
|
+
clientMessageId: data.clientMessageId
|
|
1155
|
+
} : {})
|
|
1142
1156
|
};
|
|
1143
1157
|
const clearsTyping = message.direction === 'outbound' && message.senderType !== 'visitor';
|
|
1144
1158
|
if (clearsTyping) this.clearTypingTimer();
|
|
@@ -1169,10 +1183,11 @@ export class WidgetStore {
|
|
|
1169
1183
|
messageCount: conversation.messageCount + 1
|
|
1170
1184
|
} : conversation) : current.conversations;
|
|
1171
1185
|
if (onScreen) {
|
|
1172
|
-
const
|
|
1186
|
+
const shown = adoptOwnServerRows(current.messages, [message]);
|
|
1187
|
+
const alreadyPresent = shown.some(existing => existing.id === message.id);
|
|
1173
1188
|
return {
|
|
1174
1189
|
conversations,
|
|
1175
|
-
messages: alreadyPresent ? adoptServerAttachments(
|
|
1190
|
+
messages: alreadyPresent ? adoptServerAttachments(shown, [message]) : [...shown, message],
|
|
1176
1191
|
agentTyping: clearsTyping ? false : current.agentTyping
|
|
1177
1192
|
};
|
|
1178
1193
|
}
|
|
@@ -1583,11 +1598,12 @@ export class WidgetStore {
|
|
|
1583
1598
|
this.setState(current => {
|
|
1584
1599
|
// Live replies and optimistic sends may have arrived since this page
|
|
1585
1600
|
// was requested. Keep them, preferring the live copy for shared ids.
|
|
1586
|
-
const liveIds = new Set(current.messages.map(message => message.id));
|
|
1587
1601
|
const page = this.hydrateMessages(result.data);
|
|
1602
|
+
const live = adoptOwnServerRows(current.messages, page);
|
|
1603
|
+
const liveIds = new Set(live.map(message => message.id));
|
|
1588
1604
|
const history = page.filter(message => !liveIds.has(message.id));
|
|
1589
1605
|
return {
|
|
1590
|
-
messages: sortByCreatedAt([...history, ...adoptServerAttachments(
|
|
1606
|
+
messages: sortByCreatedAt([...history, ...adoptServerAttachments(live, page)]),
|
|
1591
1607
|
messagesLoading: false,
|
|
1592
1608
|
hasMoreMessages: result.meta.hasMore,
|
|
1593
1609
|
messageCursor: result.meta.nextCursor
|
|
@@ -1655,14 +1671,15 @@ export class WidgetStore {
|
|
|
1655
1671
|
if (this.state.activeConversationId !== conversationId) return;
|
|
1656
1672
|
const fresh = this.hydrateMessages(result.data);
|
|
1657
1673
|
this.setState(current => {
|
|
1658
|
-
const
|
|
1674
|
+
const own = adoptOwnServerRows(current.messages, fresh);
|
|
1675
|
+
const known = new Set(own.map(message => message.id));
|
|
1659
1676
|
const missing = fresh.filter(message => !known.has(message.id));
|
|
1660
|
-
const upgraded = adoptServerStatuses(adoptServerDeletions(adoptServerAttachments(
|
|
1677
|
+
const upgraded = adoptServerStatuses(adoptServerDeletions(adoptServerAttachments(own, fresh), fresh), fresh);
|
|
1661
1678
|
if (missing.length === 0) return upgraded === current.messages ? {} : {
|
|
1662
1679
|
messages: upgraded
|
|
1663
1680
|
};
|
|
1664
|
-
const local =
|
|
1665
|
-
const hasServerMessages = local.length <
|
|
1681
|
+
const local = own.filter(isLocalMessage);
|
|
1682
|
+
const hasServerMessages = local.length < own.length;
|
|
1666
1683
|
// No overlap means the gap is longer than a page: restart from the
|
|
1667
1684
|
// newest page (keeping unsent bubbles) rather than leave a hole.
|
|
1668
1685
|
if (missing.length === fresh.length || !hasServerMessages) {
|
|
@@ -1687,6 +1704,40 @@ export class WidgetStore {
|
|
|
1687
1704
|
}
|
|
1688
1705
|
}
|
|
1689
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
|
+
|
|
1690
1741
|
/** A history page (newest-first) as the oldest-first list the UI renders. */
|
|
1691
1742
|
hydrateMessages(page) {
|
|
1692
1743
|
return page.map(message => ({
|
|
@@ -1734,9 +1785,11 @@ export class WidgetStore {
|
|
|
1734
1785
|
|
|
1735
1786
|
/**
|
|
1736
1787
|
* Rejects when the message wasn't sent, so the composer can keep
|
|
1737
|
-
* attachments for a retry. A text-only bubble is marked failed
|
|
1738
|
-
* attachments is removed instead — the
|
|
1739
|
-
* 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.
|
|
1740
1793
|
*/
|
|
1741
1794
|
async sendMessage(body, attachments = [], agentUiAction) {
|
|
1742
1795
|
const trimmed = body.trim();
|
|
@@ -1744,7 +1797,6 @@ export class WidgetStore {
|
|
|
1744
1797
|
const {
|
|
1745
1798
|
visitorProfile
|
|
1746
1799
|
} = this.state;
|
|
1747
|
-
const generation = this.threadGeneration;
|
|
1748
1800
|
const tempId = `temp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1749
1801
|
const optimistic = {
|
|
1750
1802
|
id: tempId,
|
|
@@ -1768,20 +1820,58 @@ export class WidgetStore {
|
|
|
1768
1820
|
status: 'pending',
|
|
1769
1821
|
createdAt: new Date().toISOString()
|
|
1770
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));
|
|
1771
1828
|
this.setState(current => ({
|
|
1772
|
-
messages: [...current.messages, optimistic],
|
|
1829
|
+
messages: [...current.messages.filter(message => !superseded.includes(message.id)), optimistic],
|
|
1773
1830
|
prefillText: ''
|
|
1774
1831
|
}));
|
|
1775
1832
|
this.track('widget_message_sent', {
|
|
1776
1833
|
hasText: !!trimmed,
|
|
1777
1834
|
attachmentCount: attachments.length
|
|
1778
1835
|
});
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
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]
|
|
1784
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
|
+
};
|
|
1785
1875
|
const epoch = this.epoch;
|
|
1786
1876
|
|
|
1787
1877
|
// A draft's first send creates the conversation. Anything sent from the
|
|
@@ -1807,24 +1897,41 @@ export class WidgetStore {
|
|
|
1807
1897
|
}
|
|
1808
1898
|
let createdId = null;
|
|
1809
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;
|
|
1810
1924
|
const result = await this.client.sendMessage({
|
|
1811
1925
|
conversationId: conversationId || undefined,
|
|
1812
1926
|
forceNewConversation: !conversationId,
|
|
1813
1927
|
body: trimmed,
|
|
1814
|
-
attachments:
|
|
1815
|
-
name,
|
|
1816
|
-
url,
|
|
1817
|
-
contentType,
|
|
1818
|
-
size
|
|
1819
|
-
}) => ({
|
|
1820
|
-
name,
|
|
1821
|
-
url,
|
|
1822
|
-
contentType,
|
|
1823
|
-
size
|
|
1824
|
-
})) : undefined,
|
|
1928
|
+
attachments: sentAttachments.length > 0 ? sentAttachments : undefined,
|
|
1825
1929
|
visitorName: visitorProfile.name,
|
|
1826
1930
|
visitorEmail: visitorProfile.email,
|
|
1827
|
-
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
|
|
1828
1935
|
});
|
|
1829
1936
|
if (this.isStale(epoch)) return;
|
|
1830
1937
|
if (ownCreation) createdId = result.conversationId;
|
|
@@ -1861,8 +1968,15 @@ export class WidgetStore {
|
|
|
1861
1968
|
} : {})
|
|
1862
1969
|
};
|
|
1863
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;
|
|
1864
1975
|
} catch (error) {
|
|
1865
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;
|
|
1866
1980
|
markFailed();
|
|
1867
1981
|
throw error;
|
|
1868
1982
|
} finally {
|
|
@@ -1873,6 +1987,8 @@ export class WidgetStore {
|
|
|
1873
1987
|
// Anything held back for the draft now has a home (or goes to unread).
|
|
1874
1988
|
this.replayDraftEvents();
|
|
1875
1989
|
}
|
|
1990
|
+
// Waits for every send to settle, so this runs when the last one does.
|
|
1991
|
+
if (!this.isStale(epoch)) void this.syncServerCopies();
|
|
1876
1992
|
}
|
|
1877
1993
|
}
|
|
1878
1994
|
async identifyVisitor(data) {
|
|
@@ -2346,11 +2462,6 @@ function createConversationCreation(generation) {
|
|
|
2346
2462
|
};
|
|
2347
2463
|
}
|
|
2348
2464
|
|
|
2349
|
-
/** Optimistic visitor bubbles the server hasn't acknowledged (pending/failed). */
|
|
2350
|
-
function isLocalMessage(message) {
|
|
2351
|
-
return message.id.startsWith('temp-');
|
|
2352
|
-
}
|
|
2353
|
-
|
|
2354
2465
|
/** A "Seen" missed while the stream was down shows up on the next re-read. */
|
|
2355
2466
|
function adoptServerStatuses(messages, server) {
|
|
2356
2467
|
const seen = new Set(server.filter(message => message.status === 'read').map(message => message.id));
|
|
@@ -2404,17 +2515,82 @@ function adoptServerAttachments(messages, server) {
|
|
|
2404
2515
|
let changed = false;
|
|
2405
2516
|
const next = messages.map(message => {
|
|
2406
2517
|
const copy = serverCopies.get(message.id);
|
|
2407
|
-
|
|
2408
|
-
|
|
2518
|
+
if (!copy || !hasLocalAttachments(message)) return message;
|
|
2519
|
+
const attachments = mergeAttachments(message.attachments, copy.attachments);
|
|
2520
|
+
if (attachments === message.attachments) return message;
|
|
2409
2521
|
changed = true;
|
|
2410
2522
|
return {
|
|
2411
2523
|
...message,
|
|
2412
|
-
attachments
|
|
2524
|
+
attachments
|
|
2413
2525
|
};
|
|
2414
2526
|
});
|
|
2415
2527
|
return changed ? next : messages;
|
|
2416
2528
|
}
|
|
2417
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
|
+
|
|
2418
2594
|
/** Stable, so same-instant rows keep their order; bad dates sort as oldest. */
|
|
2419
2595
|
function sortByCreatedAt(messages) {
|
|
2420
2596
|
const time = message => Date.parse(message.createdAt) || 0;
|