@repzo/widget-react-native 1.0.0 → 1.0.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/README.md +167 -74
- package/lib/commonjs/api/client.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 +19 -0
- package/lib/commonjs/i18n.js.map +1 -1
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/locales.js +16 -0
- package/lib/commonjs/locales.js.map +1 -1
- package/lib/commonjs/state/widget-store.js +147 -13
- package/lib/commonjs/state/widget-store.js.map +1 -1
- package/lib/commonjs/types.js +14 -0
- package/lib/commonjs/types.js.map +1 -1
- package/lib/commonjs/ui/WidgetProvider.js +53 -8
- 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/screens/chat/ChatView.js +198 -18
- package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js +19 -0
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
- package/lib/module/api/client.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 +19 -0
- package/lib/module/i18n.js.map +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/locales.js +16 -0
- package/lib/module/locales.js.map +1 -1
- package/lib/module/state/widget-store.js +147 -13
- package/lib/module/state/widget-store.js.map +1 -1
- package/lib/module/types.js +14 -0
- package/lib/module/types.js.map +1 -1
- package/lib/module/ui/WidgetProvider.js +53 -8
- 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/screens/chat/ChatView.js +201 -21
- package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/module/ui/screens/chat/ChatView.styles.js +19 -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/attachments/pickers.d.ts +81 -0
- package/lib/typescript/commonjs/attachments/pickers.d.ts.map +1 -0
- package/lib/typescript/commonjs/i18n.d.ts +16 -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 +23 -2
- package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/commonjs/types.d.ts +74 -0
- package/lib/typescript/commonjs/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/AttachmentChips.d.ts +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/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 +18 -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 +28 -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/attachments/pickers.d.ts +81 -0
- package/lib/typescript/module/attachments/pickers.d.ts.map +1 -0
- package/lib/typescript/module/i18n.d.ts +16 -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 +23 -2
- package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/module/types.d.ts +74 -0
- package/lib/typescript/module/types.d.ts.map +1 -1
- package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/AttachmentChips.d.ts +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/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 +18 -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 +28 -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 +2 -2
- package/src/attachments/pickers.ts +515 -0
- package/src/i18n.ts +20 -0
- package/src/index.tsx +4 -0
- package/src/locales.ts +16 -0
- package/src/state/widget-store.ts +152 -16
- package/src/types.ts +79 -0
- package/src/ui/WidgetProvider.tsx +65 -6
- 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/screens/chat/ChatView.styles.ts +19 -0
- package/src/ui/screens/chat/ChatView.tsx +235 -16
- package/src/ui/screens/chat/ChatView.types.ts +38 -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.1';
|
|
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. */
|
|
@@ -232,6 +242,9 @@ export class WidgetStore {
|
|
|
232
242
|
|
|
233
243
|
getState = (): WidgetState => this.state;
|
|
234
244
|
|
|
245
|
+
/** Composer identity: explicit opens reset it; a draft receiving its server ID does not. */
|
|
246
|
+
getThreadGeneration = (): number => this.threadGeneration;
|
|
247
|
+
|
|
235
248
|
private setState(patch: Partial<WidgetState> | ((current: WidgetState) => Partial<WidgetState>)) {
|
|
236
249
|
const previous = this.state;
|
|
237
250
|
const resolved = typeof patch === 'function' ? patch(previous) : patch;
|
|
@@ -239,6 +252,13 @@ export class WidgetStore {
|
|
|
239
252
|
// The total is always derived from the per-thread map, so no surface can
|
|
240
253
|
// zero the badge while threads stay unread.
|
|
241
254
|
next.unreadCount = sumUnread(next.unreadByConversation);
|
|
255
|
+
// The in-app card only lives while the messenger is closed and its thread
|
|
256
|
+
// is unread — opening the widget, reading the thread (here or on another
|
|
257
|
+
// device) or logging out all take it down without each path clearing it.
|
|
258
|
+
const card = next.inAppNotification;
|
|
259
|
+
if (card && (next.isOpen || !next.unreadByConversation[card.conversationId])) {
|
|
260
|
+
next.inAppNotification = null;
|
|
261
|
+
}
|
|
242
262
|
this.state = next;
|
|
243
263
|
|
|
244
264
|
if (next.unreadCount !== previous.unreadCount) {
|
|
@@ -286,6 +306,10 @@ export class WidgetStore {
|
|
|
286
306
|
}
|
|
287
307
|
const baseUrl = assertSecureBaseUrl(options.baseUrl?.trim() || DEFAULT_BASE_URL);
|
|
288
308
|
if (options.onOpenUrl !== undefined) setOpenUrlHandler(options.onOpenUrl ?? null);
|
|
309
|
+
if (options.attachments !== undefined) setAttachmentOptions(options.attachments ?? null);
|
|
310
|
+
if (options.inAppNotifications !== undefined) {
|
|
311
|
+
this.inAppNotificationsEnabled = options.inAppNotifications !== false;
|
|
312
|
+
}
|
|
289
313
|
|
|
290
314
|
const sameTarget =
|
|
291
315
|
this.bootConfig?.baseUrl === baseUrl &&
|
|
@@ -496,8 +520,12 @@ export class WidgetStore {
|
|
|
496
520
|
if (this.pendingConversationId) {
|
|
497
521
|
const conversationId = this.pendingConversationId;
|
|
498
522
|
this.pendingConversationId = null;
|
|
523
|
+
// Launched into a thread (push tap): that is this session's launch, so
|
|
524
|
+
// a later silent re-init must not offer the card either.
|
|
525
|
+
this.launchCardOffered = true;
|
|
499
526
|
void this.openConversation(conversationId);
|
|
500
527
|
} else {
|
|
528
|
+
this.showUnreadOnLaunch();
|
|
501
529
|
// A re-init after a 401 means the stream was down: catch the open
|
|
502
530
|
// thread up (a no-op when nothing is on screen).
|
|
503
531
|
void this.refreshOpenThread();
|
|
@@ -711,6 +739,7 @@ export class WidgetStore {
|
|
|
711
739
|
this.seenMessageIds = [];
|
|
712
740
|
this.visitorTypingActive = false;
|
|
713
741
|
this.lastTypingPingAt = 0;
|
|
742
|
+
this.launchCardOffered = false;
|
|
714
743
|
this.setState({ ...USER_SCOPED_STATE });
|
|
715
744
|
|
|
716
745
|
const token = await this.client.getToken();
|
|
@@ -1072,7 +1101,9 @@ export class WidgetStore {
|
|
|
1072
1101
|
const alreadyPresent = current.messages.some((existing) => existing.id === message.id);
|
|
1073
1102
|
return {
|
|
1074
1103
|
conversations,
|
|
1075
|
-
messages: alreadyPresent
|
|
1104
|
+
messages: alreadyPresent
|
|
1105
|
+
? adoptServerAttachments(current.messages, [message])
|
|
1106
|
+
: [...current.messages, message],
|
|
1076
1107
|
agentTyping: clearsTyping ? false : current.agentTyping,
|
|
1077
1108
|
};
|
|
1078
1109
|
}
|
|
@@ -1086,7 +1117,23 @@ export class WidgetStore {
|
|
|
1086
1117
|
this.persistUnread(unreadByConversation);
|
|
1087
1118
|
|
|
1088
1119
|
countedAsUnread = true;
|
|
1089
|
-
|
|
1120
|
+
if (!this.inAppNotificationsEnabled || current.isOpen) {
|
|
1121
|
+
return { conversations, unreadByConversation };
|
|
1122
|
+
}
|
|
1123
|
+
// Newest reply wins: one card, not a stack, like the launcher badge.
|
|
1124
|
+
const conversation = conversations.find((item) => item.id === conversationId);
|
|
1125
|
+
return {
|
|
1126
|
+
conversations,
|
|
1127
|
+
unreadByConversation,
|
|
1128
|
+
inAppNotification: {
|
|
1129
|
+
conversationId,
|
|
1130
|
+
messageId,
|
|
1131
|
+
senderName: message.senderName || conversation?.agentName || null,
|
|
1132
|
+
senderAvatar: conversation?.agentAvatar ?? null,
|
|
1133
|
+
preview: (message.bodyPlain ?? '').trim(),
|
|
1134
|
+
hasAttachments: (message.attachments?.length ?? 0) > 0,
|
|
1135
|
+
},
|
|
1136
|
+
};
|
|
1090
1137
|
});
|
|
1091
1138
|
|
|
1092
1139
|
// The visitor is reading it live — move the server cursor too, or the
|
|
@@ -1329,6 +1376,42 @@ export class WidgetStore {
|
|
|
1329
1376
|
this.setState({ prefillText: prefill });
|
|
1330
1377
|
}
|
|
1331
1378
|
|
|
1379
|
+
/**
|
|
1380
|
+
* Replies that arrived while the app was closed get the same card a live
|
|
1381
|
+
* reply would, once per launch — so a visitor without push (not set up on
|
|
1382
|
+
* the channel, or permission denied) still sees them. Only the newest unread
|
|
1383
|
+
* thread; silent re-inits and foreground returns never re-offer it.
|
|
1384
|
+
*/
|
|
1385
|
+
private showUnreadOnLaunch(): void {
|
|
1386
|
+
if (this.launchCardOffered || !this.inAppNotificationsEnabled) return;
|
|
1387
|
+
this.launchCardOffered = true;
|
|
1388
|
+
const { isOpen, inAppNotification, conversations, unreadByConversation } = this.state;
|
|
1389
|
+
if (isOpen || inAppNotification) return;
|
|
1390
|
+
|
|
1391
|
+
const time = (conversation: Conversation) =>
|
|
1392
|
+
Date.parse(conversation.lastMessageAt ?? conversation.createdAt) || 0;
|
|
1393
|
+
const newest = conversations
|
|
1394
|
+
.filter((conversation) => (unreadByConversation[conversation.id] ?? 0) > 0)
|
|
1395
|
+
.sort((a, b) => time(b) - time(a))[0];
|
|
1396
|
+
if (!newest) return;
|
|
1397
|
+
|
|
1398
|
+
this.setState({
|
|
1399
|
+
inAppNotification: {
|
|
1400
|
+
conversationId: newest.id,
|
|
1401
|
+
messageId: `launch-${newest.id}`,
|
|
1402
|
+
senderName: newest.agentName,
|
|
1403
|
+
senderAvatar: newest.agentAvatar,
|
|
1404
|
+
preview: (newest.snippet ?? '').trim(),
|
|
1405
|
+
hasAttachments: false,
|
|
1406
|
+
},
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
/** The visitor swiped the in-app card away; the thread stays unread. */
|
|
1411
|
+
dismissInAppNotification(): void {
|
|
1412
|
+
if (this.state.inAppNotification) this.setState({ inAppNotification: null });
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1332
1415
|
dismissOutboundMessage(): void {
|
|
1333
1416
|
const outbound = this.state.outboundMessage;
|
|
1334
1417
|
if (outbound?.id) this.track('widget_outbound_dismissed', { outboundId: outbound.id });
|
|
@@ -1405,11 +1488,10 @@ export class WidgetStore {
|
|
|
1405
1488
|
// Live replies and optimistic sends may have arrived since this page
|
|
1406
1489
|
// was requested. Keep them, preferring the live copy for shared ids.
|
|
1407
1490
|
const liveIds = new Set(current.messages.map((message) => message.id));
|
|
1408
|
-
const
|
|
1409
|
-
|
|
1410
|
-
);
|
|
1491
|
+
const page = this.hydrateMessages(result.data);
|
|
1492
|
+
const history = page.filter((message) => !liveIds.has(message.id));
|
|
1411
1493
|
return {
|
|
1412
|
-
messages: sortByCreatedAt([...history, ...current.messages]),
|
|
1494
|
+
messages: sortByCreatedAt([...history, ...adoptServerAttachments(current.messages, page)]),
|
|
1413
1495
|
messagesLoading: false,
|
|
1414
1496
|
hasMoreMessages: result.meta.hasMore,
|
|
1415
1497
|
messageCursor: result.meta.nextCursor,
|
|
@@ -1475,7 +1557,8 @@ export class WidgetStore {
|
|
|
1475
1557
|
this.setState((current) => {
|
|
1476
1558
|
const known = new Set(current.messages.map((message) => message.id));
|
|
1477
1559
|
const missing = fresh.filter((message) => !known.has(message.id));
|
|
1478
|
-
|
|
1560
|
+
const upgraded = adoptServerAttachments(current.messages, fresh);
|
|
1561
|
+
if (missing.length === 0) return upgraded === current.messages ? {} : { messages: upgraded };
|
|
1479
1562
|
|
|
1480
1563
|
const local = current.messages.filter(isLocalMessage);
|
|
1481
1564
|
const hasServerMessages = local.length < current.messages.length;
|
|
@@ -1488,7 +1571,7 @@ export class WidgetStore {
|
|
|
1488
1571
|
messageCursor: result.meta.nextCursor,
|
|
1489
1572
|
};
|
|
1490
1573
|
}
|
|
1491
|
-
return { messages: sortByCreatedAt([...
|
|
1574
|
+
return { messages: sortByCreatedAt([...upgraded, ...missing]) };
|
|
1492
1575
|
});
|
|
1493
1576
|
// The server just moved the read cursor — mirror it locally.
|
|
1494
1577
|
if (this.state.unreadByConversation[conversationId]) {
|
|
@@ -1509,9 +1592,15 @@ export class WidgetStore {
|
|
|
1509
1592
|
.reverse();
|
|
1510
1593
|
}
|
|
1511
1594
|
|
|
1595
|
+
/**
|
|
1596
|
+
* Rejects when the message wasn't sent, so the composer can keep
|
|
1597
|
+
* attachments for a retry. A text-only bubble is marked failed; one with
|
|
1598
|
+
* attachments is removed instead — the composer's chips are the retry, and
|
|
1599
|
+
* keeping both would leave a duplicate after a successful resend.
|
|
1600
|
+
*/
|
|
1512
1601
|
async sendMessage(
|
|
1513
1602
|
body: string,
|
|
1514
|
-
attachments:
|
|
1603
|
+
attachments: OutgoingAttachment[] = [],
|
|
1515
1604
|
agentUiAction?: AgentUiAction
|
|
1516
1605
|
): Promise<void> {
|
|
1517
1606
|
const trimmed = body.trim();
|
|
@@ -1528,7 +1617,22 @@ export class WidgetStore {
|
|
|
1528
1617
|
senderName: visitorProfile.name || 'You',
|
|
1529
1618
|
body: trimmed ? `<p>${trimmed}</p>` : '',
|
|
1530
1619
|
bodyPlain: trimmed,
|
|
1531
|
-
|
|
1620
|
+
// The local preview, never a data URL: the bubble shows up at once and
|
|
1621
|
+
// state never holds megabytes of base64. Without a local file the
|
|
1622
|
+
// bubble shows the name until the server copy replaces it.
|
|
1623
|
+
attachments:
|
|
1624
|
+
attachments.length > 0
|
|
1625
|
+
? attachments.map((attachment, index) => ({
|
|
1626
|
+
id: `${tempId}-${index}`,
|
|
1627
|
+
name: attachment.name,
|
|
1628
|
+
url:
|
|
1629
|
+
attachment.previewUrl && !/^data:/i.test(attachment.previewUrl)
|
|
1630
|
+
? attachment.previewUrl
|
|
1631
|
+
: '',
|
|
1632
|
+
contentType: attachment.contentType,
|
|
1633
|
+
size: attachment.size,
|
|
1634
|
+
}))
|
|
1635
|
+
: null,
|
|
1532
1636
|
metadata: null,
|
|
1533
1637
|
status: 'pending',
|
|
1534
1638
|
createdAt: new Date().toISOString(),
|
|
@@ -1545,9 +1649,12 @@ export class WidgetStore {
|
|
|
1545
1649
|
|
|
1546
1650
|
const markFailed = () =>
|
|
1547
1651
|
this.setState((current) => ({
|
|
1548
|
-
messages:
|
|
1549
|
-
|
|
1550
|
-
|
|
1652
|
+
messages:
|
|
1653
|
+
attachments.length > 0
|
|
1654
|
+
? current.messages.filter((message) => message.id !== tempId)
|
|
1655
|
+
: current.messages.map((message) =>
|
|
1656
|
+
message.id === tempId ? { ...message, status: 'failed' } : message
|
|
1657
|
+
),
|
|
1551
1658
|
}));
|
|
1552
1659
|
|
|
1553
1660
|
const epoch = this.epoch;
|
|
@@ -1570,7 +1677,7 @@ export class WidgetStore {
|
|
|
1570
1677
|
// don't open a thread nobody is looking at.
|
|
1571
1678
|
if (!conversationId && this.threadGeneration !== generation) {
|
|
1572
1679
|
markFailed();
|
|
1573
|
-
|
|
1680
|
+
throw new Error('The conversation this message was queued for was never created');
|
|
1574
1681
|
}
|
|
1575
1682
|
}
|
|
1576
1683
|
|
|
@@ -1580,7 +1687,15 @@ export class WidgetStore {
|
|
|
1580
1687
|
conversationId: conversationId || undefined,
|
|
1581
1688
|
forceNewConversation: !conversationId,
|
|
1582
1689
|
body: trimmed,
|
|
1583
|
-
attachments:
|
|
1690
|
+
attachments:
|
|
1691
|
+
attachments.length > 0
|
|
1692
|
+
? attachments.map(({ name, url, contentType, size }) => ({
|
|
1693
|
+
name,
|
|
1694
|
+
url,
|
|
1695
|
+
contentType,
|
|
1696
|
+
size,
|
|
1697
|
+
}))
|
|
1698
|
+
: undefined,
|
|
1584
1699
|
visitorName: visitorProfile.name,
|
|
1585
1700
|
visitorEmail: visitorProfile.email,
|
|
1586
1701
|
agentUiAction,
|
|
@@ -1736,6 +1851,27 @@ function isLocalMessage(message: Message): boolean {
|
|
|
1736
1851
|
return message.id.startsWith('temp-');
|
|
1737
1852
|
}
|
|
1738
1853
|
|
|
1854
|
+
/**
|
|
1855
|
+
* A sent visitor message keeps its local previews (a `file://` URI, or no URL
|
|
1856
|
+
* at all) until the server copy shows up — the stream echo or a history page.
|
|
1857
|
+
* Its attachments, the only ones that can be opened, then replace them.
|
|
1858
|
+
*/
|
|
1859
|
+
function adoptServerAttachments(messages: Message[], server: Message[]): Message[] {
|
|
1860
|
+
const serverCopies = new Map(
|
|
1861
|
+
server.filter((message) => message.attachments?.length).map((message) => [message.id, message])
|
|
1862
|
+
);
|
|
1863
|
+
if (serverCopies.size === 0) return messages;
|
|
1864
|
+
let changed = false;
|
|
1865
|
+
const next = messages.map((message) => {
|
|
1866
|
+
const copy = serverCopies.get(message.id);
|
|
1867
|
+
const hasLocal = message.attachments?.some((attachment) => !/^https?:\/\//i.test(attachment.url));
|
|
1868
|
+
if (!copy || !hasLocal) return message;
|
|
1869
|
+
changed = true;
|
|
1870
|
+
return { ...message, attachments: copy.attachments };
|
|
1871
|
+
});
|
|
1872
|
+
return changed ? next : messages;
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1739
1875
|
/** Stable, so same-instant rows keep their order; bad dates sort as oldest. */
|
|
1740
1876
|
function sortByCreatedAt(messages: Message[]): Message[] {
|
|
1741
1877
|
const time = (message: Message) => Date.parse(message.createdAt) || 0;
|
package/src/types.ts
CHANGED
|
@@ -77,6 +77,21 @@ export interface WidgetAttachment {
|
|
|
77
77
|
size: number;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* A visitor attachment on its way to `POST /api/widget/messages`. `url` is a
|
|
82
|
+
* base64 data URL (`data:<mime>;base64,<…>`); the server stores it and echoes
|
|
83
|
+
* the message back with a regular media URL.
|
|
84
|
+
*/
|
|
85
|
+
export interface OutgoingAttachment {
|
|
86
|
+
name: string;
|
|
87
|
+
url: string;
|
|
88
|
+
contentType: string;
|
|
89
|
+
/** Decoded bytes. */
|
|
90
|
+
size: number;
|
|
91
|
+
/** Local URI the optimistic bubble renders until the server copy exists. */
|
|
92
|
+
previewUrl?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
80
95
|
export interface Conversation {
|
|
81
96
|
id: string;
|
|
82
97
|
subject: string | null;
|
|
@@ -178,6 +193,55 @@ export interface BootOptions {
|
|
|
178
193
|
*/
|
|
179
194
|
bundleId?: string;
|
|
180
195
|
};
|
|
196
|
+
/**
|
|
197
|
+
* Visitor attachments (photos and files). Works out of the box with
|
|
198
|
+
* `expo-image-picker` / `expo-document-picker` installed; see
|
|
199
|
+
* `RepzoWidgetAttachmentOptions` to disable it or bring your own picker.
|
|
200
|
+
*/
|
|
201
|
+
attachments?: RepzoWidgetAttachmentOptions;
|
|
202
|
+
/**
|
|
203
|
+
* Show a card at the bottom of the app when a team reply arrives while the
|
|
204
|
+
* messenger is closed (default true). It stays until the visitor taps it
|
|
205
|
+
* (opens the conversation) or swipes it away. Requires
|
|
206
|
+
* `<RepzoWidgetProvider>` to be mounted.
|
|
207
|
+
*/
|
|
208
|
+
inAppNotifications?: boolean;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** An option in the composer's attach menu. */
|
|
212
|
+
export type AttachmentSource = 'photo' | 'camera' | 'file';
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* A file returned by a host `attachments.pick`. Give `base64` (raw or as a
|
|
216
|
+
* data URL) or a `uri` the SDK can read (`file://`; read with
|
|
217
|
+
* `expo-file-system` when installed, else `fetch`).
|
|
218
|
+
*/
|
|
219
|
+
export interface RepzoPickedFile {
|
|
220
|
+
name: string;
|
|
221
|
+
/** MIME type, e.g. `image/jpeg`. Guessed from the name when empty. */
|
|
222
|
+
type: string;
|
|
223
|
+
/** Bytes, when known — lets an oversized file be refused before it's read. */
|
|
224
|
+
size?: number;
|
|
225
|
+
uri?: string;
|
|
226
|
+
base64?: string;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export interface RepzoWidgetAttachmentOptions {
|
|
230
|
+
/**
|
|
231
|
+
* Default true. False hides the attach button. It is also hidden when the
|
|
232
|
+
* channel turns attachments off in Workstation, or when no picker exists
|
|
233
|
+
* (no `pick` below and neither `expo-image-picker` nor
|
|
234
|
+
* `expo-document-picker` installed).
|
|
235
|
+
*/
|
|
236
|
+
enabled?: boolean;
|
|
237
|
+
/**
|
|
238
|
+
* Your own picker, replacing the Expo ones — e.g. `react-native-image-picker`
|
|
239
|
+
* or `react-native-document-picker`. Resolve `null` or `[]` on cancel. The
|
|
240
|
+
* SDK still enforces the limits (5 files, 1.5 MB each, 6 MB together).
|
|
241
|
+
*/
|
|
242
|
+
pick?: (source: AttachmentSource) => Promise<RepzoPickedFile[] | null>;
|
|
243
|
+
/** Menu options offered with `pick`. Default all three. Ignored without `pick`. */
|
|
244
|
+
sources?: AttachmentSource[];
|
|
181
245
|
}
|
|
182
246
|
|
|
183
247
|
// ─── Agent UI blocks ────────────────────────────────────────────────────────
|
|
@@ -442,4 +506,19 @@ export interface WidgetState {
|
|
|
442
506
|
outboundMessage: OutboundMessage | null;
|
|
443
507
|
/** Name/email captured from pre-chat or `loginUserWithUserAttributes`. */
|
|
444
508
|
visitorProfile: { name?: string; email?: string; phone?: string };
|
|
509
|
+
/**
|
|
510
|
+
* The newest team reply the visitor hasn't seen, shown as a card over the
|
|
511
|
+
* app. Only set while the messenger is closed and its thread is unread.
|
|
512
|
+
*/
|
|
513
|
+
inAppNotification: InAppNotification | null;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
export interface InAppNotification {
|
|
517
|
+
conversationId: string;
|
|
518
|
+
messageId: string;
|
|
519
|
+
senderName: string | null;
|
|
520
|
+
senderAvatar: string | null;
|
|
521
|
+
/** Plain-text preview; empty when the reply was only attachments. */
|
|
522
|
+
preview: string;
|
|
523
|
+
hasAttachments: boolean;
|
|
445
524
|
}
|
|
@@ -18,12 +18,20 @@ import {
|
|
|
18
18
|
} from 'react-native';
|
|
19
19
|
|
|
20
20
|
import { isTerminalErrorCode } from '../api/client';
|
|
21
|
+
import {
|
|
22
|
+
formatBytes,
|
|
23
|
+
getAttachmentSources,
|
|
24
|
+
MAX_ATTACHMENT_BYTES,
|
|
25
|
+
MAX_ATTACHMENTS,
|
|
26
|
+
pickAttachments,
|
|
27
|
+
} from '../attachments/pickers';
|
|
21
28
|
import { controller } from '../controller';
|
|
22
29
|
import { buildLabels, createTranslator, formatLabel } from '../i18n';
|
|
23
30
|
import { useWidgetState } from '../state/use-widget-state';
|
|
24
|
-
import type { AgentUiAction,
|
|
31
|
+
import type { AgentUiAction, OutgoingAttachment, WidgetSpaceType } from '../types';
|
|
25
32
|
import { WidgetErrorBoundary } from './components/ErrorBoundary';
|
|
26
33
|
import { AlertIcon } from './components/icons';
|
|
34
|
+
import { InAppNotification } from './components/InAppNotification';
|
|
27
35
|
import { OutboundMessageCard } from './components/OutboundMessageCard';
|
|
28
36
|
import { WidgetPanel } from './components/WidgetPanel';
|
|
29
37
|
import { useKeyboardOverlap } from './hooks/use-keyboard-overlap';
|
|
@@ -108,16 +116,40 @@ export function RepzoWidgetProvider({
|
|
|
108
116
|
[labels]
|
|
109
117
|
);
|
|
110
118
|
|
|
119
|
+
const attachLabels = useMemo(
|
|
120
|
+
() => ({
|
|
121
|
+
button: labels.attachFile,
|
|
122
|
+
photoLibrary: labels.attachPhotoLibrary,
|
|
123
|
+
takePhoto: labels.attachTakePhoto,
|
|
124
|
+
file: labels.attachChooseFile,
|
|
125
|
+
cancel: labels.cancel,
|
|
126
|
+
remove: labels.removeAttachment,
|
|
127
|
+
tooLarge: formatLabel(labels.attachmentTooLarge, { max: formatBytes(MAX_ATTACHMENT_BYTES) }),
|
|
128
|
+
tooMany: formatLabel(labels.attachmentTooMany, { count: MAX_ATTACHMENTS }),
|
|
129
|
+
tooLargeTogether: labels.attachmentsTooLargeTogether,
|
|
130
|
+
failed: labels.attachmentFailed,
|
|
131
|
+
cameraDenied: labels.cameraPermissionDenied,
|
|
132
|
+
}),
|
|
133
|
+
[labels]
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// Read per render: `boot({ attachments })` sets it outside React state.
|
|
137
|
+
const attachmentSources = theme.attachmentsEnabled ? getAttachmentSources() : [];
|
|
138
|
+
|
|
111
139
|
const handleClose = useCallback(() => store.hide(), [store]);
|
|
112
140
|
|
|
141
|
+
const handleOpenNotification = useCallback(
|
|
142
|
+
(conversationId: string) => void store.openConversation(conversationId),
|
|
143
|
+
[store]
|
|
144
|
+
);
|
|
145
|
+
const handleDismissNotification = useCallback(() => store.dismissInAppNotification(), [store]);
|
|
146
|
+
|
|
113
147
|
const handleTabChange = useCallback((tab: WidgetSpaceType) => store.setActiveTab(tab), [store]);
|
|
114
148
|
|
|
149
|
+
// Rejections reach ChatView, which keeps unsent attachments for a retry.
|
|
115
150
|
const handleSendMessage = useCallback(
|
|
116
|
-
(body: string, attachments?:
|
|
117
|
-
store.sendMessage(body, attachments ?? [], action)
|
|
118
|
-
// The failed bubble already surfaces this to the visitor.
|
|
119
|
-
});
|
|
120
|
-
},
|
|
151
|
+
(body: string, attachments?: OutgoingAttachment[], action?: AgentUiAction) =>
|
|
152
|
+
store.sendMessage(body, attachments ?? [], action),
|
|
121
153
|
[store]
|
|
122
154
|
);
|
|
123
155
|
|
|
@@ -211,6 +243,7 @@ export function RepzoWidgetProvider({
|
|
|
211
243
|
if (state.view === 'chat') {
|
|
212
244
|
return (
|
|
213
245
|
<ChatView
|
|
246
|
+
key={store.getThreadGeneration()}
|
|
214
247
|
messages={state.messages}
|
|
215
248
|
loading={state.messagesLoading}
|
|
216
249
|
hasMoreMessages={state.hasMoreMessages}
|
|
@@ -240,7 +273,15 @@ export function RepzoWidgetProvider({
|
|
|
240
273
|
procedureSend: labels.procedureSend,
|
|
241
274
|
procedureSent: labels.procedureSent,
|
|
242
275
|
blocks: blockLabels,
|
|
276
|
+
attach: attachLabels,
|
|
277
|
+
viewer: {
|
|
278
|
+
close: labels.close,
|
|
279
|
+
loadFailed: labels.imageLoadFailed,
|
|
280
|
+
openExternally: labels.openImageExternally,
|
|
281
|
+
},
|
|
243
282
|
}}
|
|
283
|
+
attachmentSources={attachmentSources}
|
|
284
|
+
onPickAttachments={pickAttachments}
|
|
244
285
|
onSendMessage={handleSendMessage}
|
|
245
286
|
onIdentifyVisitor={(data) => store.identifyVisitor(data)}
|
|
246
287
|
onLoadMore={() => store.loadMoreMessages()}
|
|
@@ -319,6 +360,24 @@ export function RepzoWidgetProvider({
|
|
|
319
360
|
return (
|
|
320
361
|
<>
|
|
321
362
|
{children}
|
|
363
|
+
{state.inAppNotification ? (
|
|
364
|
+
<InAppNotification
|
|
365
|
+
// A new reply remounts the card, so it slides in fresh.
|
|
366
|
+
key={state.inAppNotification.messageId}
|
|
367
|
+
notification={state.inAppNotification}
|
|
368
|
+
color={theme.primaryColor}
|
|
369
|
+
bottomInset={insets.bottom}
|
|
370
|
+
labels={{
|
|
371
|
+
replyFrom: labels.notificationReplyFrom,
|
|
372
|
+
newReply: labels.notificationNewReply,
|
|
373
|
+
attachment: labels.notificationAttachment,
|
|
374
|
+
openHint: labels.notificationOpenHint,
|
|
375
|
+
dismiss: labels.dismiss,
|
|
376
|
+
}}
|
|
377
|
+
onOpen={handleOpenNotification}
|
|
378
|
+
onDismiss={handleDismissNotification}
|
|
379
|
+
/>
|
|
380
|
+
) : null}
|
|
322
381
|
<Modal
|
|
323
382
|
visible={state.isOpen}
|
|
324
383
|
animationType="slide"
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Picked-but-unsent attachments above the composer: a thumbnail for images, a
|
|
3
|
+
* name + size card for everything else, each with its own remove button.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Image, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
formatBytes,
|
|
10
|
+
isImageAttachment,
|
|
11
|
+
type DraftAttachment,
|
|
12
|
+
} from '../../attachments/pickers';
|
|
13
|
+
import { formatLabel } from '../../i18n';
|
|
14
|
+
import { fontSize, palette, radius, spacing, withAlpha } from '../styles/theme';
|
|
15
|
+
import { START_ALIGN } from '../utils/rtl';
|
|
16
|
+
import { CloseIcon } from './icons';
|
|
17
|
+
|
|
18
|
+
interface AttachmentChipsProps {
|
|
19
|
+
attachments: DraftAttachment[];
|
|
20
|
+
/** While a send is in flight the chips are dimmed and can't be removed. */
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
/** Keeps its `{name}` placeholder; filled per chip. */
|
|
23
|
+
removeLabel: string;
|
|
24
|
+
onRemove: (id: string) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function AttachmentChips({
|
|
28
|
+
attachments,
|
|
29
|
+
disabled,
|
|
30
|
+
removeLabel,
|
|
31
|
+
onRemove,
|
|
32
|
+
}: AttachmentChipsProps) {
|
|
33
|
+
return (
|
|
34
|
+
<ScrollView
|
|
35
|
+
horizontal
|
|
36
|
+
showsHorizontalScrollIndicator={false}
|
|
37
|
+
keyboardShouldPersistTaps="handled"
|
|
38
|
+
style={styles.scroller}
|
|
39
|
+
contentContainerStyle={styles.row}>
|
|
40
|
+
{attachments.map((attachment) => (
|
|
41
|
+
<View key={attachment.id} style={[styles.chip, disabled && styles.chipDisabled]}>
|
|
42
|
+
{isImageAttachment(attachment.contentType) ? (
|
|
43
|
+
<Image
|
|
44
|
+
source={{ uri: attachment.previewUri }}
|
|
45
|
+
style={styles.thumbnail}
|
|
46
|
+
resizeMode="cover"
|
|
47
|
+
accessible
|
|
48
|
+
accessibilityLabel={attachment.name}
|
|
49
|
+
accessibilityIgnoresInvertColors
|
|
50
|
+
/>
|
|
51
|
+
) : (
|
|
52
|
+
<View style={styles.file}>
|
|
53
|
+
<Text style={styles.fileName} numberOfLines={1}>
|
|
54
|
+
{attachment.name}
|
|
55
|
+
</Text>
|
|
56
|
+
<Text style={styles.fileSize}>{formatBytes(attachment.size)}</Text>
|
|
57
|
+
</View>
|
|
58
|
+
)}
|
|
59
|
+
<Pressable
|
|
60
|
+
onPress={() => onRemove(attachment.id)}
|
|
61
|
+
disabled={disabled}
|
|
62
|
+
accessibilityRole="button"
|
|
63
|
+
accessibilityLabel={formatLabel(removeLabel, { name: attachment.name })}
|
|
64
|
+
accessibilityState={{ disabled: !!disabled }}
|
|
65
|
+
// 22pt badge + 11pt on every side = a 44pt target.
|
|
66
|
+
hitSlop={11}
|
|
67
|
+
style={({ pressed }) => [styles.remove, pressed && styles.removePressed]}>
|
|
68
|
+
<CloseIcon size={10} color={palette.white} />
|
|
69
|
+
</Pressable>
|
|
70
|
+
</View>
|
|
71
|
+
))}
|
|
72
|
+
</ScrollView>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const CHIP_HEIGHT = 64;
|
|
77
|
+
|
|
78
|
+
const styles = StyleSheet.create({
|
|
79
|
+
scroller: {
|
|
80
|
+
flexGrow: 0,
|
|
81
|
+
marginBottom: spacing.sm,
|
|
82
|
+
},
|
|
83
|
+
row: {
|
|
84
|
+
gap: spacing.sm,
|
|
85
|
+
},
|
|
86
|
+
chip: {
|
|
87
|
+
height: CHIP_HEIGHT,
|
|
88
|
+
borderRadius: radius.md,
|
|
89
|
+
overflow: 'hidden',
|
|
90
|
+
backgroundColor: palette.surfaceAlt,
|
|
91
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
92
|
+
borderColor: palette.border,
|
|
93
|
+
},
|
|
94
|
+
chipDisabled: {
|
|
95
|
+
opacity: 0.5,
|
|
96
|
+
},
|
|
97
|
+
thumbnail: {
|
|
98
|
+
width: CHIP_HEIGHT,
|
|
99
|
+
height: CHIP_HEIGHT,
|
|
100
|
+
},
|
|
101
|
+
file: {
|
|
102
|
+
width: 156,
|
|
103
|
+
height: CHIP_HEIGHT,
|
|
104
|
+
justifyContent: 'center',
|
|
105
|
+
paddingStart: spacing.md,
|
|
106
|
+
// Clear of the remove badge in the top corner.
|
|
107
|
+
paddingEnd: spacing.xl + spacing.sm,
|
|
108
|
+
},
|
|
109
|
+
fileName: {
|
|
110
|
+
textAlign: START_ALIGN,
|
|
111
|
+
fontSize: fontSize.meta,
|
|
112
|
+
fontWeight: '600',
|
|
113
|
+
color: palette.ink,
|
|
114
|
+
},
|
|
115
|
+
fileSize: {
|
|
116
|
+
textAlign: START_ALIGN,
|
|
117
|
+
fontSize: fontSize.caption,
|
|
118
|
+
color: palette.textMuted,
|
|
119
|
+
marginTop: 2,
|
|
120
|
+
},
|
|
121
|
+
remove: {
|
|
122
|
+
position: 'absolute',
|
|
123
|
+
top: spacing.xs,
|
|
124
|
+
end: spacing.xs,
|
|
125
|
+
width: 22,
|
|
126
|
+
height: 22,
|
|
127
|
+
borderRadius: 11,
|
|
128
|
+
alignItems: 'center',
|
|
129
|
+
justifyContent: 'center',
|
|
130
|
+
backgroundColor: withAlpha(palette.ink, 0.72),
|
|
131
|
+
},
|
|
132
|
+
removePressed: {
|
|
133
|
+
opacity: 0.7,
|
|
134
|
+
},
|
|
135
|
+
});
|