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