@repzo/widget-react-native 1.0.0 → 1.0.4
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/README.md +167 -74
- package/lib/commonjs/api/client.js +3 -1
- package/lib/commonjs/api/client.js.map +1 -1
- package/lib/commonjs/api/realtime.js +2 -0
- package/lib/commonjs/api/realtime.js.map +1 -1
- package/lib/commonjs/attachments/pickers.js +493 -0
- package/lib/commonjs/attachments/pickers.js.map +1 -0
- package/lib/commonjs/i18n.js +45 -0
- package/lib/commonjs/i18n.js.map +1 -1
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/locales.js +21 -0
- package/lib/commonjs/locales.js.map +1 -1
- package/lib/commonjs/state/widget-store.js +256 -18
- package/lib/commonjs/state/widget-store.js.map +1 -1
- package/lib/commonjs/types.js +16 -0
- package/lib/commonjs/types.js.map +1 -1
- package/lib/commonjs/ui/WidgetProvider.js +70 -10
- package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
- package/lib/commonjs/ui/components/AttachmentChips.js +135 -0
- package/lib/commonjs/ui/components/AttachmentChips.js.map +1 -0
- package/lib/commonjs/ui/components/AttachmentMenu.js +125 -0
- package/lib/commonjs/ui/components/AttachmentMenu.js.map +1 -0
- package/lib/commonjs/ui/components/ImageViewer.js +181 -0
- package/lib/commonjs/ui/components/ImageViewer.js.map +1 -0
- package/lib/commonjs/ui/components/InAppNotification.js +210 -0
- package/lib/commonjs/ui/components/InAppNotification.js.map +1 -0
- package/lib/commonjs/ui/components/TeamAvatars.js +63 -0
- package/lib/commonjs/ui/components/TeamAvatars.js.map +1 -0
- package/lib/commonjs/ui/components/icons.js +50 -0
- package/lib/commonjs/ui/components/icons.js.map +1 -1
- package/lib/commonjs/ui/screens/chat/ChatView.js +303 -24
- package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js +70 -0
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
- package/lib/module/api/client.js +3 -1
- package/lib/module/api/client.js.map +1 -1
- package/lib/module/api/realtime.js +2 -0
- package/lib/module/api/realtime.js.map +1 -1
- package/lib/module/attachments/pickers.js +479 -0
- package/lib/module/attachments/pickers.js.map +1 -0
- package/lib/module/i18n.js +44 -0
- package/lib/module/i18n.js.map +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/locales.js +21 -0
- package/lib/module/locales.js.map +1 -1
- package/lib/module/state/widget-store.js +256 -18
- package/lib/module/state/widget-store.js.map +1 -1
- package/lib/module/types.js +16 -0
- package/lib/module/types.js.map +1 -1
- package/lib/module/ui/WidgetProvider.js +71 -11
- package/lib/module/ui/WidgetProvider.js.map +1 -1
- package/lib/module/ui/components/AttachmentChips.js +131 -0
- package/lib/module/ui/components/AttachmentChips.js.map +1 -0
- package/lib/module/ui/components/AttachmentMenu.js +120 -0
- package/lib/module/ui/components/AttachmentMenu.js.map +1 -0
- package/lib/module/ui/components/ImageViewer.js +177 -0
- package/lib/module/ui/components/ImageViewer.js.map +1 -0
- package/lib/module/ui/components/InAppNotification.js +208 -0
- package/lib/module/ui/components/InAppNotification.js.map +1 -0
- package/lib/module/ui/components/TeamAvatars.js +59 -0
- package/lib/module/ui/components/TeamAvatars.js.map +1 -0
- package/lib/module/ui/components/icons.js +49 -0
- package/lib/module/ui/components/icons.js.map +1 -1
- package/lib/module/ui/screens/chat/ChatView.js +306 -27
- package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/module/ui/screens/chat/ChatView.styles.js +70 -0
- package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
- package/lib/typescript/commonjs/api/client.d.ts +8 -2
- package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
- package/lib/typescript/commonjs/api/realtime.d.ts.map +1 -1
- package/lib/typescript/commonjs/attachments/pickers.d.ts +81 -0
- package/lib/typescript/commonjs/attachments/pickers.d.ts.map +1 -0
- package/lib/typescript/commonjs/i18n.d.ts +23 -0
- package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +1 -1
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/locales.d.ts.map +1 -1
- package/lib/typescript/commonjs/state/widget-store.d.ts +40 -2
- package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/commonjs/types.d.ts +88 -1
- 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 +16 -0
- package/lib/typescript/commonjs/ui/components/AttachmentChips.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/AttachmentMenu.d.ts +28 -0
- package/lib/typescript/commonjs/ui/components/AttachmentMenu.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts +23 -0
- package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/InAppNotification.d.ts +28 -0
- package/lib/typescript/commonjs/ui/components/InAppNotification.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/TeamAvatars.d.ts +17 -0
- package/lib/typescript/commonjs/ui/components/TeamAvatars.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/icons.d.ts +2 -0
- package/lib/typescript/commonjs/ui/components/icons.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts +3 -2
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +69 -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 +41 -2
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
- package/lib/typescript/module/api/client.d.ts +8 -2
- package/lib/typescript/module/api/client.d.ts.map +1 -1
- package/lib/typescript/module/api/realtime.d.ts.map +1 -1
- package/lib/typescript/module/attachments/pickers.d.ts +81 -0
- package/lib/typescript/module/attachments/pickers.d.ts.map +1 -0
- package/lib/typescript/module/i18n.d.ts +23 -0
- package/lib/typescript/module/i18n.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +1 -1
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/locales.d.ts.map +1 -1
- package/lib/typescript/module/state/widget-store.d.ts +40 -2
- package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/module/types.d.ts +88 -1
- 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 +16 -0
- package/lib/typescript/module/ui/components/AttachmentChips.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/AttachmentMenu.d.ts +28 -0
- package/lib/typescript/module/ui/components/AttachmentMenu.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/ImageViewer.d.ts +23 -0
- package/lib/typescript/module/ui/components/ImageViewer.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/InAppNotification.d.ts +28 -0
- package/lib/typescript/module/ui/components/InAppNotification.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/TeamAvatars.d.ts +17 -0
- package/lib/typescript/module/ui/components/TeamAvatars.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/icons.d.ts +2 -0
- package/lib/typescript/module/ui/components/icons.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.d.ts +3 -2
- package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +69 -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 +41 -2
- package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
- package/metro.js +17 -32
- package/package.json +18 -1
- package/src/api/client.ts +5 -3
- package/src/api/realtime.ts +2 -0
- package/src/attachments/pickers.ts +515 -0
- package/src/i18n.ts +48 -0
- package/src/index.tsx +4 -0
- package/src/locales.ts +21 -0
- package/src/state/widget-store.ts +256 -19
- package/src/types.ts +95 -0
- package/src/ui/WidgetProvider.tsx +90 -9
- package/src/ui/components/AttachmentChips.tsx +135 -0
- package/src/ui/components/AttachmentMenu.tsx +132 -0
- package/src/ui/components/ImageViewer.tsx +178 -0
- package/src/ui/components/InAppNotification.tsx +225 -0
- package/src/ui/components/TeamAvatars.tsx +58 -0
- package/src/ui/components/icons.tsx +37 -0
- package/src/ui/screens/chat/ChatView.styles.ts +70 -0
- package/src/ui/screens/chat/ChatView.tsx +345 -35
- package/src/ui/screens/chat/ChatView.types.ts +51 -2
|
@@ -16,12 +16,14 @@ import { getDeviceId, rotateDeviceId } from '../device-id';
|
|
|
16
16
|
import { EventEmitter, RepzoWidgetEvents } from '../events';
|
|
17
17
|
import { PushManager } from '../push/push-notifications';
|
|
18
18
|
import { CONFIG_KEY, LEGACY_CONFIG_KEY, SecureStorage, Storage, UNREAD_KEY, USER_KEY } from '../storage';
|
|
19
|
+
import { setAttachmentOptions } from '../attachments/pickers';
|
|
19
20
|
import { DEFAULT_BASE_URL } from '../types';
|
|
20
21
|
import type {
|
|
21
22
|
AgentUiAction,
|
|
22
23
|
BootOptions,
|
|
23
24
|
Conversation,
|
|
24
25
|
Message,
|
|
26
|
+
OutgoingAttachment,
|
|
25
27
|
RepzoWidgetErrorCode,
|
|
26
28
|
WidgetAttachment,
|
|
27
29
|
WidgetConfig,
|
|
@@ -33,7 +35,7 @@ import type {
|
|
|
33
35
|
} from '../types';
|
|
34
36
|
import { setOpenUrlHandler } from '../ui/utils/safe-url';
|
|
35
37
|
|
|
36
|
-
const SDK_VERSION = '@repzo/widget-react-native@1.0.
|
|
38
|
+
const SDK_VERSION = '@repzo/widget-react-native@1.0.4';
|
|
37
39
|
|
|
38
40
|
/** The platform this session reports — mobile sessions get the Mobile SDKs overrides. */
|
|
39
41
|
function sessionPlatform(): 'ios' | 'android' | 'web' {
|
|
@@ -91,6 +93,7 @@ const INITIAL_STATE: WidgetState = {
|
|
|
91
93
|
prefillText: '',
|
|
92
94
|
outboundMessage: null,
|
|
93
95
|
visitorProfile: {},
|
|
96
|
+
inAppNotification: null,
|
|
94
97
|
};
|
|
95
98
|
|
|
96
99
|
/** Everything in state that belongs to the signed-in visitor. */
|
|
@@ -110,6 +113,7 @@ const USER_SCOPED_STATE: Partial<WidgetState> = {
|
|
|
110
113
|
prefillText: '',
|
|
111
114
|
outboundMessage: null,
|
|
112
115
|
visitorProfile: {},
|
|
116
|
+
inAppNotification: null,
|
|
113
117
|
};
|
|
114
118
|
|
|
115
119
|
export class WidgetStore {
|
|
@@ -136,6 +140,12 @@ export class WidgetStore {
|
|
|
136
140
|
|
|
137
141
|
private bootConfig: { baseUrl: string; channelAccountId: string } | null = null;
|
|
138
142
|
|
|
143
|
+
/** `boot({ inAppNotifications })` — the card over the app for new replies. */
|
|
144
|
+
private inAppNotificationsEnabled = true;
|
|
145
|
+
|
|
146
|
+
/** The launch card (see showUnreadOnLaunch) is offered once per visitor session. */
|
|
147
|
+
private launchCardOffered = false;
|
|
148
|
+
|
|
139
149
|
private bootPromise: Promise<void> | null = null;
|
|
140
150
|
|
|
141
151
|
/** Identity key of the in-flight boot — a boot for someone else is not deduped. */
|
|
@@ -158,6 +168,8 @@ export class WidgetStore {
|
|
|
158
168
|
private visitorTypingActive = false;
|
|
159
169
|
|
|
160
170
|
private seenMessageIds: string[] = [];
|
|
171
|
+
/** Statuses (e.g. "read") that arrived for an id before a send response revealed it. */
|
|
172
|
+
private earlyStatuses = new Map<string, string>();
|
|
161
173
|
|
|
162
174
|
/**
|
|
163
175
|
* Identity generation. Bumped whenever boot/login/logout is requested; every
|
|
@@ -232,6 +244,9 @@ export class WidgetStore {
|
|
|
232
244
|
|
|
233
245
|
getState = (): WidgetState => this.state;
|
|
234
246
|
|
|
247
|
+
/** Composer identity: explicit opens reset it; a draft receiving its server ID does not. */
|
|
248
|
+
getThreadGeneration = (): number => this.threadGeneration;
|
|
249
|
+
|
|
235
250
|
private setState(patch: Partial<WidgetState> | ((current: WidgetState) => Partial<WidgetState>)) {
|
|
236
251
|
const previous = this.state;
|
|
237
252
|
const resolved = typeof patch === 'function' ? patch(previous) : patch;
|
|
@@ -239,6 +254,13 @@ export class WidgetStore {
|
|
|
239
254
|
// The total is always derived from the per-thread map, so no surface can
|
|
240
255
|
// zero the badge while threads stay unread.
|
|
241
256
|
next.unreadCount = sumUnread(next.unreadByConversation);
|
|
257
|
+
// The in-app card only lives while the messenger is closed and its thread
|
|
258
|
+
// is unread — opening the widget, reading the thread (here or on another
|
|
259
|
+
// device) or logging out all take it down without each path clearing it.
|
|
260
|
+
const card = next.inAppNotification;
|
|
261
|
+
if (card && (next.isOpen || !next.unreadByConversation[card.conversationId])) {
|
|
262
|
+
next.inAppNotification = null;
|
|
263
|
+
}
|
|
242
264
|
this.state = next;
|
|
243
265
|
|
|
244
266
|
if (next.unreadCount !== previous.unreadCount) {
|
|
@@ -286,6 +308,10 @@ export class WidgetStore {
|
|
|
286
308
|
}
|
|
287
309
|
const baseUrl = assertSecureBaseUrl(options.baseUrl?.trim() || DEFAULT_BASE_URL);
|
|
288
310
|
if (options.onOpenUrl !== undefined) setOpenUrlHandler(options.onOpenUrl ?? null);
|
|
311
|
+
if (options.attachments !== undefined) setAttachmentOptions(options.attachments ?? null);
|
|
312
|
+
if (options.inAppNotifications !== undefined) {
|
|
313
|
+
this.inAppNotificationsEnabled = options.inAppNotifications !== false;
|
|
314
|
+
}
|
|
289
315
|
|
|
290
316
|
const sameTarget =
|
|
291
317
|
this.bootConfig?.baseUrl === baseUrl &&
|
|
@@ -496,8 +522,12 @@ export class WidgetStore {
|
|
|
496
522
|
if (this.pendingConversationId) {
|
|
497
523
|
const conversationId = this.pendingConversationId;
|
|
498
524
|
this.pendingConversationId = null;
|
|
525
|
+
// Launched into a thread (push tap): that is this session's launch, so
|
|
526
|
+
// a later silent re-init must not offer the card either.
|
|
527
|
+
this.launchCardOffered = true;
|
|
499
528
|
void this.openConversation(conversationId);
|
|
500
529
|
} else {
|
|
530
|
+
this.showUnreadOnLaunch();
|
|
501
531
|
// A re-init after a 401 means the stream was down: catch the open
|
|
502
532
|
// thread up (a no-op when nothing is on screen).
|
|
503
533
|
void this.refreshOpenThread();
|
|
@@ -711,6 +741,7 @@ export class WidgetStore {
|
|
|
711
741
|
this.seenMessageIds = [];
|
|
712
742
|
this.visitorTypingActive = false;
|
|
713
743
|
this.lastTypingPingAt = 0;
|
|
744
|
+
this.launchCardOffered = false;
|
|
714
745
|
this.setState({ ...USER_SCOPED_STATE });
|
|
715
746
|
|
|
716
747
|
const token = await this.client.getToken();
|
|
@@ -859,7 +890,7 @@ export class WidgetStore {
|
|
|
859
890
|
|
|
860
891
|
private handleRealtimeEvent(event: string, data: any) {
|
|
861
892
|
if (
|
|
862
|
-
(event === 'message.new' || event === 'conversation.event') &&
|
|
893
|
+
(event === 'message.new' || event === 'message.deleted' || event === 'message.status' || event === 'conversation.event') &&
|
|
863
894
|
this.shouldHoldForDraft(data?.conversationId)
|
|
864
895
|
) {
|
|
865
896
|
this.draftEvents.push({ event, data });
|
|
@@ -886,6 +917,14 @@ export class WidgetStore {
|
|
|
886
917
|
this.handleNewMessage(data);
|
|
887
918
|
break;
|
|
888
919
|
|
|
920
|
+
case 'message.deleted':
|
|
921
|
+
this.handleDeletedMessage(data);
|
|
922
|
+
break;
|
|
923
|
+
|
|
924
|
+
case 'message.status':
|
|
925
|
+
this.handleMessageStatus(data);
|
|
926
|
+
break;
|
|
927
|
+
|
|
889
928
|
case 'conversation.updated':
|
|
890
929
|
this.rememberConversationAgent(data);
|
|
891
930
|
this.setState((current) => ({
|
|
@@ -898,6 +937,7 @@ export class WidgetStore {
|
|
|
898
937
|
data.agentAvatar === undefined
|
|
899
938
|
? conversation.agentAvatar
|
|
900
939
|
: this.client.resolveUrl(data.agentAvatar),
|
|
940
|
+
agentOnline: data.agentOnline === undefined ? conversation.agentOnline : data.agentOnline,
|
|
901
941
|
}
|
|
902
942
|
: conversation
|
|
903
943
|
),
|
|
@@ -1015,6 +1055,50 @@ export class WidgetStore {
|
|
|
1015
1055
|
return false;
|
|
1016
1056
|
}
|
|
1017
1057
|
|
|
1058
|
+
/**
|
|
1059
|
+
* A teammate deleted a message: the visitor sees "This message was deleted"
|
|
1060
|
+
* in its place (they can't delete anything themselves), and the list is
|
|
1061
|
+
* refreshed so its preview stops showing the text. The id stays in
|
|
1062
|
+
* `seenMessageIds`, so a replayed `message.new` can't restore the content.
|
|
1063
|
+
*/
|
|
1064
|
+
/**
|
|
1065
|
+
* A teammate opened the conversation: the visitor's messages are now Seen.
|
|
1066
|
+
* The ids are also remembered, because the event can arrive before the send
|
|
1067
|
+
* response swaps a pending bubble's temporary id for the server id.
|
|
1068
|
+
*/
|
|
1069
|
+
private handleMessageStatus(data: any) {
|
|
1070
|
+
const ids: string[] = Array.isArray(data?.messageIds) ? data.messageIds : [];
|
|
1071
|
+
const status: string | undefined = typeof data?.status === 'string' ? data.status : undefined;
|
|
1072
|
+
if (!status || ids.length === 0) return;
|
|
1073
|
+
for (const id of ids) this.earlyStatuses.set(id, status);
|
|
1074
|
+
if (this.earlyStatuses.size > SEEN_MESSAGE_LIMIT) {
|
|
1075
|
+
this.earlyStatuses.delete(this.earlyStatuses.keys().next().value as string);
|
|
1076
|
+
}
|
|
1077
|
+
if (!this.isThreadOnScreen(data?.conversationId)) return;
|
|
1078
|
+
this.setState((current) => ({
|
|
1079
|
+
messages: current.messages.map((message) =>
|
|
1080
|
+
ids.includes(message.id) ? { ...message, status } : message
|
|
1081
|
+
),
|
|
1082
|
+
}));
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
private handleDeletedMessage(data: any) {
|
|
1086
|
+
const messageId: string | undefined = data?.messageId;
|
|
1087
|
+
if (!messageId) return;
|
|
1088
|
+
if (!this.seenMessageIds.includes(messageId)) this.isReplayedMessage(messageId);
|
|
1089
|
+
if (this.isThreadOnScreen(data?.conversationId)) {
|
|
1090
|
+
this.setState((current) => ({
|
|
1091
|
+
messages: current.messages.map((message) =>
|
|
1092
|
+
message.id === messageId ? asDeletedPlaceholder(message, new Date().toISOString()) : message
|
|
1093
|
+
),
|
|
1094
|
+
}));
|
|
1095
|
+
}
|
|
1096
|
+
if (this.state.inAppNotification?.messageId === messageId) {
|
|
1097
|
+
this.setState({ inAppNotification: null });
|
|
1098
|
+
}
|
|
1099
|
+
void this.refreshConversations();
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1018
1102
|
private handleNewMessage(data: any) {
|
|
1019
1103
|
if (this.isReplayedMessage(data?.messageId)) return;
|
|
1020
1104
|
|
|
@@ -1031,7 +1115,7 @@ export class WidgetStore {
|
|
|
1031
1115
|
body: data?.body || null,
|
|
1032
1116
|
bodyPlain: data?.bodyPlain || null,
|
|
1033
1117
|
attachments: this.hydrateAttachments(data?.attachments, messageId),
|
|
1034
|
-
metadata: data?.metadata || null,
|
|
1118
|
+
metadata: this.hydrateMetadata(data?.metadata || null),
|
|
1035
1119
|
status: 'delivered',
|
|
1036
1120
|
createdAt: data?.createdAt || new Date().toISOString(),
|
|
1037
1121
|
};
|
|
@@ -1072,7 +1156,9 @@ export class WidgetStore {
|
|
|
1072
1156
|
const alreadyPresent = current.messages.some((existing) => existing.id === message.id);
|
|
1073
1157
|
return {
|
|
1074
1158
|
conversations,
|
|
1075
|
-
messages: alreadyPresent
|
|
1159
|
+
messages: alreadyPresent
|
|
1160
|
+
? adoptServerAttachments(current.messages, [message])
|
|
1161
|
+
: [...current.messages, message],
|
|
1076
1162
|
agentTyping: clearsTyping ? false : current.agentTyping,
|
|
1077
1163
|
};
|
|
1078
1164
|
}
|
|
@@ -1086,7 +1172,23 @@ export class WidgetStore {
|
|
|
1086
1172
|
this.persistUnread(unreadByConversation);
|
|
1087
1173
|
|
|
1088
1174
|
countedAsUnread = true;
|
|
1089
|
-
|
|
1175
|
+
if (!this.inAppNotificationsEnabled || current.isOpen) {
|
|
1176
|
+
return { conversations, unreadByConversation };
|
|
1177
|
+
}
|
|
1178
|
+
// Newest reply wins: one card, not a stack, like the launcher badge.
|
|
1179
|
+
const conversation = conversations.find((item) => item.id === conversationId);
|
|
1180
|
+
return {
|
|
1181
|
+
conversations,
|
|
1182
|
+
unreadByConversation,
|
|
1183
|
+
inAppNotification: {
|
|
1184
|
+
conversationId,
|
|
1185
|
+
messageId,
|
|
1186
|
+
senderName: message.senderName || conversation?.agentName || null,
|
|
1187
|
+
senderAvatar: conversation?.agentAvatar ?? null,
|
|
1188
|
+
preview: (message.bodyPlain ?? '').trim(),
|
|
1189
|
+
hasAttachments: (message.attachments?.length ?? 0) > 0,
|
|
1190
|
+
},
|
|
1191
|
+
};
|
|
1090
1192
|
});
|
|
1091
1193
|
|
|
1092
1194
|
// The visitor is reading it live — move the server cursor too, or the
|
|
@@ -1329,6 +1431,42 @@ export class WidgetStore {
|
|
|
1329
1431
|
this.setState({ prefillText: prefill });
|
|
1330
1432
|
}
|
|
1331
1433
|
|
|
1434
|
+
/**
|
|
1435
|
+
* Replies that arrived while the app was closed get the same card a live
|
|
1436
|
+
* reply would, once per launch — so a visitor without push (not set up on
|
|
1437
|
+
* the channel, or permission denied) still sees them. Only the newest unread
|
|
1438
|
+
* thread; silent re-inits and foreground returns never re-offer it.
|
|
1439
|
+
*/
|
|
1440
|
+
private showUnreadOnLaunch(): void {
|
|
1441
|
+
if (this.launchCardOffered || !this.inAppNotificationsEnabled) return;
|
|
1442
|
+
this.launchCardOffered = true;
|
|
1443
|
+
const { isOpen, inAppNotification, conversations, unreadByConversation } = this.state;
|
|
1444
|
+
if (isOpen || inAppNotification) return;
|
|
1445
|
+
|
|
1446
|
+
const time = (conversation: Conversation) =>
|
|
1447
|
+
Date.parse(conversation.lastMessageAt ?? conversation.createdAt) || 0;
|
|
1448
|
+
const newest = conversations
|
|
1449
|
+
.filter((conversation) => (unreadByConversation[conversation.id] ?? 0) > 0)
|
|
1450
|
+
.sort((a, b) => time(b) - time(a))[0];
|
|
1451
|
+
if (!newest) return;
|
|
1452
|
+
|
|
1453
|
+
this.setState({
|
|
1454
|
+
inAppNotification: {
|
|
1455
|
+
conversationId: newest.id,
|
|
1456
|
+
messageId: `launch-${newest.id}`,
|
|
1457
|
+
senderName: newest.agentName,
|
|
1458
|
+
senderAvatar: newest.agentAvatar,
|
|
1459
|
+
preview: (newest.snippet ?? '').trim(),
|
|
1460
|
+
hasAttachments: false,
|
|
1461
|
+
},
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
/** The visitor swiped the in-app card away; the thread stays unread. */
|
|
1466
|
+
dismissInAppNotification(): void {
|
|
1467
|
+
if (this.state.inAppNotification) this.setState({ inAppNotification: null });
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1332
1470
|
dismissOutboundMessage(): void {
|
|
1333
1471
|
const outbound = this.state.outboundMessage;
|
|
1334
1472
|
if (outbound?.id) this.track('widget_outbound_dismissed', { outboundId: outbound.id });
|
|
@@ -1405,11 +1543,10 @@ export class WidgetStore {
|
|
|
1405
1543
|
// Live replies and optimistic sends may have arrived since this page
|
|
1406
1544
|
// was requested. Keep them, preferring the live copy for shared ids.
|
|
1407
1545
|
const liveIds = new Set(current.messages.map((message) => message.id));
|
|
1408
|
-
const
|
|
1409
|
-
|
|
1410
|
-
);
|
|
1546
|
+
const page = this.hydrateMessages(result.data);
|
|
1547
|
+
const history = page.filter((message) => !liveIds.has(message.id));
|
|
1411
1548
|
return {
|
|
1412
|
-
messages: sortByCreatedAt([...history, ...current.messages]),
|
|
1549
|
+
messages: sortByCreatedAt([...history, ...adoptServerAttachments(current.messages, page)]),
|
|
1413
1550
|
messagesLoading: false,
|
|
1414
1551
|
hasMoreMessages: result.meta.hasMore,
|
|
1415
1552
|
messageCursor: result.meta.nextCursor,
|
|
@@ -1475,7 +1612,11 @@ export class WidgetStore {
|
|
|
1475
1612
|
this.setState((current) => {
|
|
1476
1613
|
const known = new Set(current.messages.map((message) => message.id));
|
|
1477
1614
|
const missing = fresh.filter((message) => !known.has(message.id));
|
|
1478
|
-
|
|
1615
|
+
const upgraded = adoptServerStatuses(
|
|
1616
|
+
adoptServerDeletions(adoptServerAttachments(current.messages, fresh), fresh),
|
|
1617
|
+
fresh
|
|
1618
|
+
);
|
|
1619
|
+
if (missing.length === 0) return upgraded === current.messages ? {} : { messages: upgraded };
|
|
1479
1620
|
|
|
1480
1621
|
const local = current.messages.filter(isLocalMessage);
|
|
1481
1622
|
const hasServerMessages = local.length < current.messages.length;
|
|
@@ -1488,7 +1629,7 @@ export class WidgetStore {
|
|
|
1488
1629
|
messageCursor: result.meta.nextCursor,
|
|
1489
1630
|
};
|
|
1490
1631
|
}
|
|
1491
|
-
return { messages: sortByCreatedAt([...
|
|
1632
|
+
return { messages: sortByCreatedAt([...upgraded, ...missing]) };
|
|
1492
1633
|
});
|
|
1493
1634
|
// The server just moved the read cursor — mirror it locally.
|
|
1494
1635
|
if (this.state.unreadByConversation[conversationId]) {
|
|
@@ -1505,13 +1646,26 @@ export class WidgetStore {
|
|
|
1505
1646
|
.map((message) => ({
|
|
1506
1647
|
...message,
|
|
1507
1648
|
attachments: this.hydrateAttachments(message.attachments, message.id),
|
|
1649
|
+
metadata: this.hydrateMetadata(message.metadata),
|
|
1508
1650
|
}))
|
|
1509
1651
|
.reverse();
|
|
1510
1652
|
}
|
|
1511
1653
|
|
|
1654
|
+
/** The "joined" line's avatar can be a server-relative media path. */
|
|
1655
|
+
private hydrateMetadata(metadata: Message['metadata']): Message['metadata'] {
|
|
1656
|
+
if (!metadata || typeof metadata.agentAvatar !== 'string') return metadata ?? null;
|
|
1657
|
+
return { ...metadata, agentAvatar: this.client.resolveUrl(metadata.agentAvatar) };
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
/**
|
|
1661
|
+
* Rejects when the message wasn't sent, so the composer can keep
|
|
1662
|
+
* attachments for a retry. A text-only bubble is marked failed; one with
|
|
1663
|
+
* attachments is removed instead — the composer's chips are the retry, and
|
|
1664
|
+
* keeping both would leave a duplicate after a successful resend.
|
|
1665
|
+
*/
|
|
1512
1666
|
async sendMessage(
|
|
1513
1667
|
body: string,
|
|
1514
|
-
attachments:
|
|
1668
|
+
attachments: OutgoingAttachment[] = [],
|
|
1515
1669
|
agentUiAction?: AgentUiAction
|
|
1516
1670
|
): Promise<void> {
|
|
1517
1671
|
const trimmed = body.trim();
|
|
@@ -1528,7 +1682,22 @@ export class WidgetStore {
|
|
|
1528
1682
|
senderName: visitorProfile.name || 'You',
|
|
1529
1683
|
body: trimmed ? `<p>${trimmed}</p>` : '',
|
|
1530
1684
|
bodyPlain: trimmed,
|
|
1531
|
-
|
|
1685
|
+
// The local preview, never a data URL: the bubble shows up at once and
|
|
1686
|
+
// state never holds megabytes of base64. Without a local file the
|
|
1687
|
+
// bubble shows the name until the server copy replaces it.
|
|
1688
|
+
attachments:
|
|
1689
|
+
attachments.length > 0
|
|
1690
|
+
? attachments.map((attachment, index) => ({
|
|
1691
|
+
id: `${tempId}-${index}`,
|
|
1692
|
+
name: attachment.name,
|
|
1693
|
+
url:
|
|
1694
|
+
attachment.previewUrl && !/^data:/i.test(attachment.previewUrl)
|
|
1695
|
+
? attachment.previewUrl
|
|
1696
|
+
: '',
|
|
1697
|
+
contentType: attachment.contentType,
|
|
1698
|
+
size: attachment.size,
|
|
1699
|
+
}))
|
|
1700
|
+
: null,
|
|
1532
1701
|
metadata: null,
|
|
1533
1702
|
status: 'pending',
|
|
1534
1703
|
createdAt: new Date().toISOString(),
|
|
@@ -1545,9 +1714,12 @@ export class WidgetStore {
|
|
|
1545
1714
|
|
|
1546
1715
|
const markFailed = () =>
|
|
1547
1716
|
this.setState((current) => ({
|
|
1548
|
-
messages:
|
|
1549
|
-
|
|
1550
|
-
|
|
1717
|
+
messages:
|
|
1718
|
+
attachments.length > 0
|
|
1719
|
+
? current.messages.filter((message) => message.id !== tempId)
|
|
1720
|
+
: current.messages.map((message) =>
|
|
1721
|
+
message.id === tempId ? { ...message, status: 'failed' } : message
|
|
1722
|
+
),
|
|
1551
1723
|
}));
|
|
1552
1724
|
|
|
1553
1725
|
const epoch = this.epoch;
|
|
@@ -1570,7 +1742,7 @@ export class WidgetStore {
|
|
|
1570
1742
|
// don't open a thread nobody is looking at.
|
|
1571
1743
|
if (!conversationId && this.threadGeneration !== generation) {
|
|
1572
1744
|
markFailed();
|
|
1573
|
-
|
|
1745
|
+
throw new Error('The conversation this message was queued for was never created');
|
|
1574
1746
|
}
|
|
1575
1747
|
}
|
|
1576
1748
|
|
|
@@ -1580,7 +1752,15 @@ export class WidgetStore {
|
|
|
1580
1752
|
conversationId: conversationId || undefined,
|
|
1581
1753
|
forceNewConversation: !conversationId,
|
|
1582
1754
|
body: trimmed,
|
|
1583
|
-
attachments:
|
|
1755
|
+
attachments:
|
|
1756
|
+
attachments.length > 0
|
|
1757
|
+
? attachments.map(({ name, url, contentType, size }) => ({
|
|
1758
|
+
name,
|
|
1759
|
+
url,
|
|
1760
|
+
contentType,
|
|
1761
|
+
size,
|
|
1762
|
+
}))
|
|
1763
|
+
: undefined,
|
|
1584
1764
|
visitorName: visitorProfile.name,
|
|
1585
1765
|
visitorEmail: visitorProfile.email,
|
|
1586
1766
|
agentUiAction,
|
|
@@ -1595,7 +1775,9 @@ export class WidgetStore {
|
|
|
1595
1775
|
const messages = delivered
|
|
1596
1776
|
? current.messages.filter((message) => message.id !== tempId)
|
|
1597
1777
|
: current.messages.map((message) =>
|
|
1598
|
-
message.id === tempId
|
|
1778
|
+
message.id === tempId
|
|
1779
|
+
? { ...message, id: result.messageId, status: this.earlyStatuses.get(result.messageId) ?? 'sent' }
|
|
1780
|
+
: message
|
|
1599
1781
|
);
|
|
1600
1782
|
|
|
1601
1783
|
if (!createdId) return { messages };
|
|
@@ -1736,6 +1918,61 @@ function isLocalMessage(message: Message): boolean {
|
|
|
1736
1918
|
return message.id.startsWith('temp-');
|
|
1737
1919
|
}
|
|
1738
1920
|
|
|
1921
|
+
/**
|
|
1922
|
+
* A sent visitor message keeps its local previews (a `file://` URI, or no URL
|
|
1923
|
+
* at all) until the server copy shows up — the stream echo or a history page.
|
|
1924
|
+
* Its attachments, the only ones that can be opened, then replace them.
|
|
1925
|
+
*/
|
|
1926
|
+
/** A "Seen" missed while the stream was down shows up on the next re-read. */
|
|
1927
|
+
function adoptServerStatuses(messages: Message[], server: Message[]): Message[] {
|
|
1928
|
+
const seen = new Set(server.filter((message) => message.status === 'read').map((message) => message.id));
|
|
1929
|
+
if (seen.size === 0) return messages;
|
|
1930
|
+
let changed = false;
|
|
1931
|
+
const next = messages.map((message) => {
|
|
1932
|
+
if (!seen.has(message.id) || message.status === 'read') return message;
|
|
1933
|
+
changed = true;
|
|
1934
|
+
return { ...message, status: 'read' };
|
|
1935
|
+
});
|
|
1936
|
+
return changed ? next : messages;
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
/** Keeps who/when, drops everything the visitor could read. */
|
|
1940
|
+
function asDeletedPlaceholder(message: Message, deletedAt: string): Message {
|
|
1941
|
+
return { ...message, body: null, bodyPlain: null, attachments: null, metadata: null, deletedAt };
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
/** A deletion missed while the stream was down shows up on the next re-read. */
|
|
1945
|
+
function adoptServerDeletions(messages: Message[], server: Message[]): Message[] {
|
|
1946
|
+
const deleted = new Map(
|
|
1947
|
+
server.filter((message) => message.deletedAt).map((message) => [message.id, message.deletedAt!])
|
|
1948
|
+
);
|
|
1949
|
+
if (deleted.size === 0) return messages;
|
|
1950
|
+
let changed = false;
|
|
1951
|
+
const next = messages.map((message) => {
|
|
1952
|
+
const deletedAt = deleted.get(message.id);
|
|
1953
|
+
if (!deletedAt || message.deletedAt) return message;
|
|
1954
|
+
changed = true;
|
|
1955
|
+
return asDeletedPlaceholder(message, deletedAt);
|
|
1956
|
+
});
|
|
1957
|
+
return changed ? next : messages;
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
function adoptServerAttachments(messages: Message[], server: Message[]): Message[] {
|
|
1961
|
+
const serverCopies = new Map(
|
|
1962
|
+
server.filter((message) => message.attachments?.length).map((message) => [message.id, message])
|
|
1963
|
+
);
|
|
1964
|
+
if (serverCopies.size === 0) return messages;
|
|
1965
|
+
let changed = false;
|
|
1966
|
+
const next = messages.map((message) => {
|
|
1967
|
+
const copy = serverCopies.get(message.id);
|
|
1968
|
+
const hasLocal = message.attachments?.some((attachment) => !/^https?:\/\//i.test(attachment.url));
|
|
1969
|
+
if (!copy || !hasLocal) return message;
|
|
1970
|
+
changed = true;
|
|
1971
|
+
return { ...message, attachments: copy.attachments };
|
|
1972
|
+
});
|
|
1973
|
+
return changed ? next : messages;
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1739
1976
|
/** Stable, so same-instant rows keep their order; bad dates sort as oldest. */
|
|
1740
1977
|
function sortByCreatedAt(messages: Message[]): Message[] {
|
|
1741
1978
|
const time = (message: Message) => Date.parse(message.createdAt) || 0;
|
package/src/types.ts
CHANGED
|
@@ -21,6 +21,12 @@ export interface PreChatField {
|
|
|
21
21
|
options?: string[];
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/** A teammate's face for the stacked header / "Waiting for a teammate" avatars. */
|
|
25
|
+
export interface TeamAvatar {
|
|
26
|
+
initials: string;
|
|
27
|
+
avatarUrl: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
24
30
|
export interface WidgetConfig {
|
|
25
31
|
primaryColor: string;
|
|
26
32
|
homeLogoUrl: string | null;
|
|
@@ -66,6 +72,10 @@ export interface WidgetConfig {
|
|
|
66
72
|
identityVerificationEnabled: boolean;
|
|
67
73
|
hmacMandatory: boolean;
|
|
68
74
|
isOnline: boolean;
|
|
75
|
+
/** Header title while no teammate has joined (and after the conversation closes). */
|
|
76
|
+
workspaceName?: string | null;
|
|
77
|
+
/** Up to three teammates shown stacked beside it. Absent on older servers. */
|
|
78
|
+
teamAvatars?: TeamAvatar[];
|
|
69
79
|
}
|
|
70
80
|
|
|
71
81
|
/** Received attachments are normalized: a missing `id`/`size` is filled in. */
|
|
@@ -77,6 +87,21 @@ export interface WidgetAttachment {
|
|
|
77
87
|
size: number;
|
|
78
88
|
}
|
|
79
89
|
|
|
90
|
+
/**
|
|
91
|
+
* A visitor attachment on its way to `POST /api/widget/messages`. `url` is a
|
|
92
|
+
* base64 data URL (`data:<mime>;base64,<…>`); the server stores it and echoes
|
|
93
|
+
* the message back with a regular media URL.
|
|
94
|
+
*/
|
|
95
|
+
export interface OutgoingAttachment {
|
|
96
|
+
name: string;
|
|
97
|
+
url: string;
|
|
98
|
+
contentType: string;
|
|
99
|
+
/** Decoded bytes. */
|
|
100
|
+
size: number;
|
|
101
|
+
/** Local URI the optimistic bubble renders until the server copy exists. */
|
|
102
|
+
previewUrl?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
80
105
|
export interface Conversation {
|
|
81
106
|
id: string;
|
|
82
107
|
subject: string | null;
|
|
@@ -87,6 +112,8 @@ export interface Conversation {
|
|
|
87
112
|
createdAt: string;
|
|
88
113
|
agentName: string | null;
|
|
89
114
|
agentAvatar: string | null;
|
|
115
|
+
/** The assigned teammate's own presence ("Active"/"Away"); absent on older servers. */
|
|
116
|
+
agentOnline?: boolean | null;
|
|
90
117
|
/**
|
|
91
118
|
* Agent replies the visitor hasn't seen, computed server-side from their
|
|
92
119
|
* read cursor. Absent on older Workstation deployments — the SDK then falls
|
|
@@ -107,6 +134,8 @@ export interface Message {
|
|
|
107
134
|
metadata?: Record<string, unknown> | null;
|
|
108
135
|
status: string | null;
|
|
109
136
|
createdAt: string;
|
|
137
|
+
/** Set when a teammate deleted the message; the content fields are then empty. */
|
|
138
|
+
deletedAt?: string | null;
|
|
110
139
|
}
|
|
111
140
|
|
|
112
141
|
export interface OutboundMessage {
|
|
@@ -178,6 +207,55 @@ export interface BootOptions {
|
|
|
178
207
|
*/
|
|
179
208
|
bundleId?: string;
|
|
180
209
|
};
|
|
210
|
+
/**
|
|
211
|
+
* Visitor attachments (photos and files). Works out of the box with
|
|
212
|
+
* `expo-image-picker` / `expo-document-picker` installed; see
|
|
213
|
+
* `RepzoWidgetAttachmentOptions` to disable it or bring your own picker.
|
|
214
|
+
*/
|
|
215
|
+
attachments?: RepzoWidgetAttachmentOptions;
|
|
216
|
+
/**
|
|
217
|
+
* Show a card at the bottom of the app when a team reply arrives while the
|
|
218
|
+
* messenger is closed (default true). It stays until the visitor taps it
|
|
219
|
+
* (opens the conversation) or swipes it away. Requires
|
|
220
|
+
* `<RepzoWidgetProvider>` to be mounted.
|
|
221
|
+
*/
|
|
222
|
+
inAppNotifications?: boolean;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** An option in the composer's attach menu. */
|
|
226
|
+
export type AttachmentSource = 'photo' | 'camera' | 'file';
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* A file returned by a host `attachments.pick`. Give `base64` (raw or as a
|
|
230
|
+
* data URL) or a `uri` the SDK can read (`file://`; read with
|
|
231
|
+
* `expo-file-system` when installed, else `fetch`).
|
|
232
|
+
*/
|
|
233
|
+
export interface RepzoPickedFile {
|
|
234
|
+
name: string;
|
|
235
|
+
/** MIME type, e.g. `image/jpeg`. Guessed from the name when empty. */
|
|
236
|
+
type: string;
|
|
237
|
+
/** Bytes, when known — lets an oversized file be refused before it's read. */
|
|
238
|
+
size?: number;
|
|
239
|
+
uri?: string;
|
|
240
|
+
base64?: string;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export interface RepzoWidgetAttachmentOptions {
|
|
244
|
+
/**
|
|
245
|
+
* Default true. False hides the attach button. It is also hidden when the
|
|
246
|
+
* channel turns attachments off in Workstation, or when no picker exists
|
|
247
|
+
* (no `pick` below and neither `expo-image-picker` nor
|
|
248
|
+
* `expo-document-picker` installed).
|
|
249
|
+
*/
|
|
250
|
+
enabled?: boolean;
|
|
251
|
+
/**
|
|
252
|
+
* Your own picker, replacing the Expo ones — e.g. `react-native-image-picker`
|
|
253
|
+
* or `react-native-document-picker`. Resolve `null` or `[]` on cancel. The
|
|
254
|
+
* SDK still enforces the limits (5 files, 1.5 MB each, 6 MB together).
|
|
255
|
+
*/
|
|
256
|
+
pick?: (source: AttachmentSource) => Promise<RepzoPickedFile[] | null>;
|
|
257
|
+
/** Menu options offered with `pick`. Default all three. Ignored without `pick`. */
|
|
258
|
+
sources?: AttachmentSource[];
|
|
181
259
|
}
|
|
182
260
|
|
|
183
261
|
// ─── Agent UI blocks ────────────────────────────────────────────────────────
|
|
@@ -374,6 +452,8 @@ export interface AgentProcedureForm {
|
|
|
374
452
|
export type WidgetRealtimeEvent =
|
|
375
453
|
| 'connected'
|
|
376
454
|
| 'message.new'
|
|
455
|
+
| 'message.deleted'
|
|
456
|
+
| 'message.status'
|
|
377
457
|
| 'typing.start'
|
|
378
458
|
| 'typing.stop'
|
|
379
459
|
| 'conversation.updated'
|
|
@@ -442,4 +522,19 @@ export interface WidgetState {
|
|
|
442
522
|
outboundMessage: OutboundMessage | null;
|
|
443
523
|
/** Name/email captured from pre-chat or `loginUserWithUserAttributes`. */
|
|
444
524
|
visitorProfile: { name?: string; email?: string; phone?: string };
|
|
525
|
+
/**
|
|
526
|
+
* The newest team reply the visitor hasn't seen, shown as a card over the
|
|
527
|
+
* app. Only set while the messenger is closed and its thread is unread.
|
|
528
|
+
*/
|
|
529
|
+
inAppNotification: InAppNotification | null;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
export interface InAppNotification {
|
|
533
|
+
conversationId: string;
|
|
534
|
+
messageId: string;
|
|
535
|
+
senderName: string | null;
|
|
536
|
+
senderAvatar: string | null;
|
|
537
|
+
/** Plain-text preview; empty when the reply was only attachments. */
|
|
538
|
+
preview: string;
|
|
539
|
+
hasAttachments: boolean;
|
|
445
540
|
}
|