@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.
Files changed (126) hide show
  1. package/README.md +167 -74
  2. package/lib/commonjs/api/client.js.map +1 -1
  3. package/lib/commonjs/attachments/pickers.js +493 -0
  4. package/lib/commonjs/attachments/pickers.js.map +1 -0
  5. package/lib/commonjs/i18n.js +19 -0
  6. package/lib/commonjs/i18n.js.map +1 -1
  7. package/lib/commonjs/index.js.map +1 -1
  8. package/lib/commonjs/locales.js +16 -0
  9. package/lib/commonjs/locales.js.map +1 -1
  10. package/lib/commonjs/state/widget-store.js +147 -13
  11. package/lib/commonjs/state/widget-store.js.map +1 -1
  12. package/lib/commonjs/types.js +14 -0
  13. package/lib/commonjs/types.js.map +1 -1
  14. package/lib/commonjs/ui/WidgetProvider.js +53 -8
  15. package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
  16. package/lib/commonjs/ui/components/AttachmentChips.js +135 -0
  17. package/lib/commonjs/ui/components/AttachmentChips.js.map +1 -0
  18. package/lib/commonjs/ui/components/AttachmentMenu.js +125 -0
  19. package/lib/commonjs/ui/components/AttachmentMenu.js.map +1 -0
  20. package/lib/commonjs/ui/components/ImageViewer.js +181 -0
  21. package/lib/commonjs/ui/components/ImageViewer.js.map +1 -0
  22. package/lib/commonjs/ui/components/InAppNotification.js +210 -0
  23. package/lib/commonjs/ui/components/InAppNotification.js.map +1 -0
  24. package/lib/commonjs/ui/screens/chat/ChatView.js +198 -18
  25. package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
  26. package/lib/commonjs/ui/screens/chat/ChatView.styles.js +19 -0
  27. package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
  28. package/lib/module/api/client.js.map +1 -1
  29. package/lib/module/attachments/pickers.js +479 -0
  30. package/lib/module/attachments/pickers.js.map +1 -0
  31. package/lib/module/i18n.js +19 -0
  32. package/lib/module/i18n.js.map +1 -1
  33. package/lib/module/index.js.map +1 -1
  34. package/lib/module/locales.js +16 -0
  35. package/lib/module/locales.js.map +1 -1
  36. package/lib/module/state/widget-store.js +147 -13
  37. package/lib/module/state/widget-store.js.map +1 -1
  38. package/lib/module/types.js +14 -0
  39. package/lib/module/types.js.map +1 -1
  40. package/lib/module/ui/WidgetProvider.js +53 -8
  41. package/lib/module/ui/WidgetProvider.js.map +1 -1
  42. package/lib/module/ui/components/AttachmentChips.js +131 -0
  43. package/lib/module/ui/components/AttachmentChips.js.map +1 -0
  44. package/lib/module/ui/components/AttachmentMenu.js +120 -0
  45. package/lib/module/ui/components/AttachmentMenu.js.map +1 -0
  46. package/lib/module/ui/components/ImageViewer.js +177 -0
  47. package/lib/module/ui/components/ImageViewer.js.map +1 -0
  48. package/lib/module/ui/components/InAppNotification.js +208 -0
  49. package/lib/module/ui/components/InAppNotification.js.map +1 -0
  50. package/lib/module/ui/screens/chat/ChatView.js +201 -21
  51. package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
  52. package/lib/module/ui/screens/chat/ChatView.styles.js +19 -0
  53. package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
  54. package/lib/typescript/commonjs/api/client.d.ts +8 -2
  55. package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
  56. package/lib/typescript/commonjs/attachments/pickers.d.ts +81 -0
  57. package/lib/typescript/commonjs/attachments/pickers.d.ts.map +1 -0
  58. package/lib/typescript/commonjs/i18n.d.ts +16 -0
  59. package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/index.d.ts +1 -1
  61. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/locales.d.ts.map +1 -1
  63. package/lib/typescript/commonjs/state/widget-store.d.ts +23 -2
  64. package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
  65. package/lib/typescript/commonjs/types.d.ts +74 -0
  66. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  67. package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
  68. package/lib/typescript/commonjs/ui/components/AttachmentChips.d.ts +16 -0
  69. package/lib/typescript/commonjs/ui/components/AttachmentChips.d.ts.map +1 -0
  70. package/lib/typescript/commonjs/ui/components/AttachmentMenu.d.ts +28 -0
  71. package/lib/typescript/commonjs/ui/components/AttachmentMenu.d.ts.map +1 -0
  72. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts +23 -0
  73. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts.map +1 -0
  74. package/lib/typescript/commonjs/ui/components/InAppNotification.d.ts +28 -0
  75. package/lib/typescript/commonjs/ui/components/InAppNotification.d.ts.map +1 -0
  76. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts +3 -2
  77. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
  78. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +18 -0
  79. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  80. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +28 -2
  81. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  82. package/lib/typescript/module/api/client.d.ts +8 -2
  83. package/lib/typescript/module/api/client.d.ts.map +1 -1
  84. package/lib/typescript/module/attachments/pickers.d.ts +81 -0
  85. package/lib/typescript/module/attachments/pickers.d.ts.map +1 -0
  86. package/lib/typescript/module/i18n.d.ts +16 -0
  87. package/lib/typescript/module/i18n.d.ts.map +1 -1
  88. package/lib/typescript/module/index.d.ts +1 -1
  89. package/lib/typescript/module/index.d.ts.map +1 -1
  90. package/lib/typescript/module/locales.d.ts.map +1 -1
  91. package/lib/typescript/module/state/widget-store.d.ts +23 -2
  92. package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
  93. package/lib/typescript/module/types.d.ts +74 -0
  94. package/lib/typescript/module/types.d.ts.map +1 -1
  95. package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
  96. package/lib/typescript/module/ui/components/AttachmentChips.d.ts +16 -0
  97. package/lib/typescript/module/ui/components/AttachmentChips.d.ts.map +1 -0
  98. package/lib/typescript/module/ui/components/AttachmentMenu.d.ts +28 -0
  99. package/lib/typescript/module/ui/components/AttachmentMenu.d.ts.map +1 -0
  100. package/lib/typescript/module/ui/components/ImageViewer.d.ts +23 -0
  101. package/lib/typescript/module/ui/components/ImageViewer.d.ts.map +1 -0
  102. package/lib/typescript/module/ui/components/InAppNotification.d.ts +28 -0
  103. package/lib/typescript/module/ui/components/InAppNotification.d.ts.map +1 -0
  104. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts +3 -2
  105. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
  106. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +18 -0
  107. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  108. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +28 -2
  109. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  110. package/metro.js +17 -32
  111. package/package.json +18 -1
  112. package/src/api/client.ts +2 -2
  113. package/src/attachments/pickers.ts +515 -0
  114. package/src/i18n.ts +20 -0
  115. package/src/index.tsx +4 -0
  116. package/src/locales.ts +16 -0
  117. package/src/state/widget-store.ts +152 -16
  118. package/src/types.ts +79 -0
  119. package/src/ui/WidgetProvider.tsx +65 -6
  120. package/src/ui/components/AttachmentChips.tsx +135 -0
  121. package/src/ui/components/AttachmentMenu.tsx +132 -0
  122. package/src/ui/components/ImageViewer.tsx +178 -0
  123. package/src/ui/components/InAppNotification.tsx +225 -0
  124. package/src/ui/screens/chat/ChatView.styles.ts +19 -0
  125. package/src/ui/screens/chat/ChatView.tsx +235 -16
  126. 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.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 ? current.messages : [...current.messages, message],
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
- return { conversations, unreadByConversation };
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 history = this.hydrateMessages(result.data).filter(
1409
- (message) => !liveIds.has(message.id)
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
- if (missing.length === 0) return {};
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([...current.messages, ...missing]) };
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: WidgetAttachment[] = [],
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
- attachments: attachments.length > 0 ? attachments : null,
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: current.messages.map((message) =>
1549
- message.id === tempId ? { ...message, status: 'failed' } : message
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
- return;
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: attachments.length > 0 ? attachments : undefined,
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, WidgetAttachment, WidgetSpaceType } from '../types';
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?: WidgetAttachment[], action?: AgentUiAction) => {
117
- store.sendMessage(body, attachments ?? [], action).catch(() => {
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
+ });