@repzo/widget-react-native 1.0.8 → 1.1.0

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 (151) hide show
  1. package/CHANGELOG.md +365 -0
  2. package/README.md +56 -4
  3. package/lib/commonjs/agent-ui-blocks.js +2 -1
  4. package/lib/commonjs/agent-ui-blocks.js.map +1 -1
  5. package/lib/commonjs/api/client.js +3 -0
  6. package/lib/commonjs/api/client.js.map +1 -1
  7. package/lib/commonjs/attachments/pickers.js +3 -6
  8. package/lib/commonjs/attachments/pickers.js.map +1 -1
  9. package/lib/commonjs/device-id.js +31 -3
  10. package/lib/commonjs/device-id.js.map +1 -1
  11. package/lib/commonjs/i18n.js +26 -4
  12. package/lib/commonjs/i18n.js.map +1 -1
  13. package/lib/commonjs/locales.js +21 -1
  14. package/lib/commonjs/locales.js.map +1 -1
  15. package/lib/commonjs/push/push-notifications.js +0 -1
  16. package/lib/commonjs/push/push-notifications.js.map +1 -1
  17. package/lib/commonjs/state/widget-store.js +57 -20
  18. package/lib/commonjs/state/widget-store.js.map +1 -1
  19. package/lib/commonjs/storage.js +13 -1
  20. package/lib/commonjs/storage.js.map +1 -1
  21. package/lib/commonjs/types.js +6 -3
  22. package/lib/commonjs/types.js.map +1 -1
  23. package/lib/commonjs/ui/WidgetProvider.js +74 -47
  24. package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
  25. package/lib/commonjs/ui/components/AgentUiBlocks.js +35 -15
  26. package/lib/commonjs/ui/components/AgentUiBlocks.js.map +1 -1
  27. package/lib/commonjs/ui/components/Launcher.js +4 -1
  28. package/lib/commonjs/ui/components/Launcher.js.map +1 -1
  29. package/lib/commonjs/ui/components/Markdown.js +32 -2
  30. package/lib/commonjs/ui/components/Markdown.js.map +1 -1
  31. package/lib/commonjs/ui/components/RatingCard.js +6 -3
  32. package/lib/commonjs/ui/components/RatingCard.js.map +1 -1
  33. package/lib/commonjs/ui/hooks/use-widget-insets.js +0 -1
  34. package/lib/commonjs/ui/hooks/use-widget-insets.js.map +1 -1
  35. package/lib/commonjs/ui/screens/chat/ChatView.js +67 -29
  36. package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
  37. package/lib/commonjs/ui/utils/html.js +8 -1
  38. package/lib/commonjs/ui/utils/html.js.map +1 -1
  39. package/lib/commonjs/ui/utils/native-gradient.js +0 -1
  40. package/lib/commonjs/ui/utils/native-gradient.js.map +1 -1
  41. package/lib/module/agent-ui-blocks.js +2 -1
  42. package/lib/module/agent-ui-blocks.js.map +1 -1
  43. package/lib/module/api/client.js +3 -0
  44. package/lib/module/api/client.js.map +1 -1
  45. package/lib/module/attachments/pickers.js +3 -6
  46. package/lib/module/attachments/pickers.js.map +1 -1
  47. package/lib/module/device-id.js +31 -3
  48. package/lib/module/device-id.js.map +1 -1
  49. package/lib/module/i18n.js +26 -4
  50. package/lib/module/i18n.js.map +1 -1
  51. package/lib/module/locales.js +21 -1
  52. package/lib/module/locales.js.map +1 -1
  53. package/lib/module/push/push-notifications.js +0 -1
  54. package/lib/module/push/push-notifications.js.map +1 -1
  55. package/lib/module/state/widget-store.js +58 -20
  56. package/lib/module/state/widget-store.js.map +1 -1
  57. package/lib/module/storage.js +13 -1
  58. package/lib/module/storage.js.map +1 -1
  59. package/lib/module/types.js +6 -3
  60. package/lib/module/types.js.map +1 -1
  61. package/lib/module/ui/WidgetProvider.js +74 -47
  62. package/lib/module/ui/WidgetProvider.js.map +1 -1
  63. package/lib/module/ui/components/AgentUiBlocks.js +35 -15
  64. package/lib/module/ui/components/AgentUiBlocks.js.map +1 -1
  65. package/lib/module/ui/components/Launcher.js +4 -1
  66. package/lib/module/ui/components/Launcher.js.map +1 -1
  67. package/lib/module/ui/components/Markdown.js +32 -3
  68. package/lib/module/ui/components/Markdown.js.map +1 -1
  69. package/lib/module/ui/components/RatingCard.js +6 -3
  70. package/lib/module/ui/components/RatingCard.js.map +1 -1
  71. package/lib/module/ui/hooks/use-widget-insets.js +0 -1
  72. package/lib/module/ui/hooks/use-widget-insets.js.map +1 -1
  73. package/lib/module/ui/screens/chat/ChatView.js +68 -30
  74. package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
  75. package/lib/module/ui/utils/html.js +8 -1
  76. package/lib/module/ui/utils/html.js.map +1 -1
  77. package/lib/module/ui/utils/native-gradient.js +0 -1
  78. package/lib/module/ui/utils/native-gradient.js.map +1 -1
  79. package/lib/typescript/commonjs/agent-ui-blocks.d.ts.map +1 -1
  80. package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
  81. package/lib/typescript/commonjs/attachments/pickers.d.ts.map +1 -1
  82. package/lib/typescript/commonjs/device-id.d.ts.map +1 -1
  83. package/lib/typescript/commonjs/i18n.d.ts +22 -1
  84. package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
  85. package/lib/typescript/commonjs/locales.d.ts.map +1 -1
  86. package/lib/typescript/commonjs/push/push-notifications.d.ts.map +1 -1
  87. package/lib/typescript/commonjs/state/widget-store.d.ts +13 -7
  88. package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
  89. package/lib/typescript/commonjs/storage.d.ts.map +1 -1
  90. package/lib/typescript/commonjs/types.d.ts +15 -7
  91. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  92. package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
  93. package/lib/typescript/commonjs/ui/components/AgentUiBlocks.d.ts +14 -0
  94. package/lib/typescript/commonjs/ui/components/AgentUiBlocks.d.ts.map +1 -1
  95. package/lib/typescript/commonjs/ui/components/Launcher.d.ts.map +1 -1
  96. package/lib/typescript/commonjs/ui/components/Markdown.d.ts +8 -0
  97. package/lib/typescript/commonjs/ui/components/Markdown.d.ts.map +1 -1
  98. package/lib/typescript/commonjs/ui/components/RatingCard.d.ts.map +1 -1
  99. package/lib/typescript/commonjs/ui/hooks/use-widget-insets.d.ts.map +1 -1
  100. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
  101. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +10 -0
  102. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  103. package/lib/typescript/commonjs/ui/utils/html.d.ts.map +1 -1
  104. package/lib/typescript/commonjs/ui/utils/native-gradient.d.ts.map +1 -1
  105. package/lib/typescript/module/agent-ui-blocks.d.ts.map +1 -1
  106. package/lib/typescript/module/api/client.d.ts.map +1 -1
  107. package/lib/typescript/module/attachments/pickers.d.ts.map +1 -1
  108. package/lib/typescript/module/device-id.d.ts.map +1 -1
  109. package/lib/typescript/module/i18n.d.ts +22 -1
  110. package/lib/typescript/module/i18n.d.ts.map +1 -1
  111. package/lib/typescript/module/locales.d.ts.map +1 -1
  112. package/lib/typescript/module/push/push-notifications.d.ts.map +1 -1
  113. package/lib/typescript/module/state/widget-store.d.ts +13 -7
  114. package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
  115. package/lib/typescript/module/storage.d.ts.map +1 -1
  116. package/lib/typescript/module/types.d.ts +15 -7
  117. package/lib/typescript/module/types.d.ts.map +1 -1
  118. package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
  119. package/lib/typescript/module/ui/components/AgentUiBlocks.d.ts +14 -0
  120. package/lib/typescript/module/ui/components/AgentUiBlocks.d.ts.map +1 -1
  121. package/lib/typescript/module/ui/components/Launcher.d.ts.map +1 -1
  122. package/lib/typescript/module/ui/components/Markdown.d.ts +8 -0
  123. package/lib/typescript/module/ui/components/Markdown.d.ts.map +1 -1
  124. package/lib/typescript/module/ui/components/RatingCard.d.ts.map +1 -1
  125. package/lib/typescript/module/ui/hooks/use-widget-insets.d.ts.map +1 -1
  126. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
  127. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +10 -0
  128. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  129. package/lib/typescript/module/ui/utils/html.d.ts.map +1 -1
  130. package/lib/typescript/module/ui/utils/native-gradient.d.ts.map +1 -1
  131. package/package.json +14 -4
  132. package/src/agent-ui-blocks.ts +2 -1
  133. package/src/api/client.ts +6 -0
  134. package/src/attachments/pickers.ts +3 -6
  135. package/src/device-id.ts +34 -3
  136. package/src/i18n.ts +25 -2
  137. package/src/locales.ts +20 -0
  138. package/src/push/push-notifications.ts +0 -1
  139. package/src/state/widget-store.ts +52 -19
  140. package/src/storage.ts +18 -1
  141. package/src/types.ts +15 -7
  142. package/src/ui/WidgetProvider.tsx +88 -46
  143. package/src/ui/components/AgentUiBlocks.tsx +40 -12
  144. package/src/ui/components/Launcher.tsx +6 -1
  145. package/src/ui/components/Markdown.tsx +34 -3
  146. package/src/ui/components/RatingCard.tsx +6 -2
  147. package/src/ui/hooks/use-widget-insets.ts +0 -1
  148. package/src/ui/screens/chat/ChatView.tsx +77 -42
  149. package/src/ui/screens/chat/ChatView.types.ts +10 -0
  150. package/src/ui/utils/html.ts +9 -1
  151. package/src/ui/utils/native-gradient.ts +0 -1
package/src/api/client.ts CHANGED
@@ -215,6 +215,12 @@ export class WidgetApiClient {
215
215
  ...(params.user.userId ? { user_id: params.user.userId } : {}),
216
216
  ...(params.user.userHash ? { user_hash: params.user.userHash } : {}),
217
217
  ...(params.user.account?.id ? { account: params.user.account } : {}),
218
+ ...(params.user.signedUpAt !== undefined && params.user.signedUpAt !== null
219
+ ? {
220
+ signed_up_at:
221
+ params.user.signedUpAt instanceof Date ? params.user.signedUpAt.toISOString() : params.user.signedUpAt,
222
+ }
223
+ : {}),
218
224
  }
219
225
  : undefined;
220
226
 
@@ -63,12 +63,12 @@ export interface PickResult {
63
63
  }
64
64
 
65
65
  // ─── Host integrations ─────────────────────────────────────────────────────
66
- // Resolved once at module load. Each `require` is static so Metro can see it,
67
- // and `withRepzoWidget` (metro.js) maps an uninstalled one to an empty module.
66
+ // Resolved once at module load. Each `require` is static so Metro can see it;
67
+ // with `allowOptionalDependencies` (see metro.js) an uninstalled one bundles as
68
+ // a require that throws, which the `try` around it catches.
68
69
 
69
70
  let imagePicker: any = null;
70
71
  try {
71
- // eslint-disable-next-line @typescript-eslint/no-var-requires
72
72
  const mod = require('expo-image-picker');
73
73
  if (typeof mod?.launchImageLibraryAsync === 'function') imagePicker = mod;
74
74
  } catch {
@@ -77,7 +77,6 @@ try {
77
77
 
78
78
  let documentPicker: any = null;
79
79
  try {
80
- // eslint-disable-next-line @typescript-eslint/no-var-requires
81
80
  const mod = require('expo-document-picker');
82
81
  if (typeof mod?.getDocumentAsync === 'function') documentPicker = mod;
83
82
  } catch {
@@ -86,7 +85,6 @@ try {
86
85
 
87
86
  let fileSystem: any = null;
88
87
  try {
89
- // eslint-disable-next-line @typescript-eslint/no-var-requires
90
88
  fileSystem = require('expo-file-system') ?? null;
91
89
  } catch {
92
90
  fileSystem = null;
@@ -94,7 +92,6 @@ try {
94
92
 
95
93
  let imageManipulator: any = null;
96
94
  try {
97
- // eslint-disable-next-line @typescript-eslint/no-var-requires
98
95
  const mod = require('expo-image-manipulator');
99
96
  if (
100
97
  typeof mod?.manipulateAsync === 'function' ||
package/src/device-id.ts CHANGED
@@ -10,9 +10,33 @@ import { DEVICE_ID_KEY, Storage } from './storage';
10
10
  let cached: string | null = null;
11
11
 
12
12
  /**
13
- * 16 random bytes from `crypto.getRandomValues` when the runtime has it (RN
14
- * with a polyfill, newer Hermes); otherwise Math.random, which is fine for a
15
- * correlation id but guessable.
13
+ * The host's `expo-crypto`, if installed — Hermes has no `crypto` global
14
+ * unless the app adds a polyfill. Loaded like every other optional peer.
15
+ */
16
+ function expoCryptoBytes(bytes: Uint8Array): boolean {
17
+ try {
18
+ const mod = require('expo-crypto');
19
+ if (typeof mod?.getRandomValues === 'function') {
20
+ mod.getRandomValues(bytes);
21
+ return true;
22
+ }
23
+ if (typeof mod?.getRandomBytes === 'function') {
24
+ const random: unknown = mod.getRandomBytes(bytes.length);
25
+ if (random instanceof Uint8Array && random.length === bytes.length) {
26
+ bytes.set(random);
27
+ return true;
28
+ }
29
+ }
30
+ } catch {
31
+ // Not installed, or its native module is missing (Expo Go, tests).
32
+ }
33
+ return false;
34
+ }
35
+
36
+ /**
37
+ * 16 random bytes from `crypto.getRandomValues` (RN with a polyfill, newer
38
+ * Hermes), then `expo-crypto`; only without either, Math.random — fine for a
39
+ * correlation id but guessable, so development builds say so.
16
40
  */
17
41
  function randomBytes(): Uint8Array {
18
42
  const bytes = new Uint8Array(16);
@@ -26,6 +50,13 @@ function randomBytes(): Uint8Array {
26
50
  // Present but unusable (some polyfills throw) — fall through.
27
51
  }
28
52
  }
53
+ if (expoCryptoBytes(bytes)) return bytes;
54
+ if (typeof __DEV__ !== 'undefined' && __DEV__) {
55
+ console.warn(
56
+ '[repzo-widget] no secure random source, so the device id uses Math.random. Install ' +
57
+ 'expo-crypto or react-native-get-random-values — see "Requirements" in the README.'
58
+ );
59
+ }
29
60
  for (let index = 0; index < bytes.length; index += 1) {
30
61
  bytes[index] = (Math.random() * 256) | 0;
31
62
  }
package/src/i18n.ts CHANGED
@@ -32,16 +32,17 @@ export function createTranslator(config: WidgetConfig | null, locale: string): T
32
32
  /**
33
33
  * Dashboard copy that's written per language: the default language's text is
34
34
  * the plain config field and every other enabled language's lives under the
35
- * same key in `translations`.
35
+ * same key in `translations`. The offline message is written the same way.
36
36
  */
37
37
  const WELCOME_TEXT_KEYS = [
38
38
  'welcomeHeading',
39
39
  'welcomeSubheading',
40
40
  'startConversationLabel',
41
41
  'replyTimeLabel',
42
+ 'offlineMessage',
42
43
  ] as const;
43
44
 
44
- /** The config with its welcome text in the active locale. */
45
+ /** The config with its welcome text and offline message in the active locale. */
45
46
  export function localizeWelcomeText(config: WidgetConfig | null, locale: string): WidgetConfig | null {
46
47
  if (!config) return config;
47
48
  const translate = createTranslator(config, locale);
@@ -136,6 +137,14 @@ export const DEFAULT_LABELS = {
136
137
  // Forms.
137
138
  procedureSend: 'Send details',
138
139
  procedureSent: 'Sent',
140
+ procedureShareDetails: 'Share details',
141
+ procedureDetailsSent: 'Details sent',
142
+ procedureHide: 'Hide',
143
+ procedureFieldCount: '{count} fields',
144
+ procedureFieldCountOne: '1 field',
145
+ procedureTitle: 'Details',
146
+ procedureEmailPlaceholder: 'name@example.com',
147
+ procedurePhonePlaceholder: '+962 7...',
139
148
  fieldRequired: '{field} is required',
140
149
  invalidEmail: 'Enter a valid email address',
141
150
  working: 'Working...',
@@ -154,6 +163,20 @@ export const DEFAULT_LABELS = {
154
163
  blockChooseTime: 'Choose a time',
155
164
  blockProfessional: 'Professional',
156
165
  blockPriceOffer: 'Price offer',
166
+ blockSubtotal: 'Subtotal',
167
+ blockDiscount: 'Discount',
168
+ blockTax: 'Tax',
169
+ blockShipping: 'Shipping',
170
+ blockTotal: 'Total',
171
+ blockMinutes: '{count} min',
172
+ blockSave: 'Save',
173
+ blockAnyAvailable: 'Any available',
174
+ blockWithProvider: 'With {name}',
175
+ blockBookTime: 'Book this time',
176
+ blockValidUntil: 'Valid until {date}',
177
+ // Floating launcher button (screen reader names).
178
+ launcherOpen: 'Open chat',
179
+ launcherClose: 'Close chat',
157
180
  } as const;
158
181
 
159
182
  export type LabelKey = keyof typeof DEFAULT_LABELS;
package/src/locales.ts CHANGED
@@ -90,6 +90,13 @@ const ar: LabelMap = {
90
90
  rating5: 'ممتاز',
91
91
  procedureSend: 'إرسال التفاصيل',
92
92
  procedureSent: 'تم الإرسال',
93
+ procedureShareDetails: 'مشاركة التفاصيل',
94
+ procedureDetailsSent: 'تم إرسال التفاصيل',
95
+ procedureHide: 'إخفاء',
96
+ // "Fields: 5" sidesteps Arabic's count-dependent plural forms.
97
+ procedureFieldCount: 'الحقول: {count}',
98
+ procedureFieldCountOne: 'حقل واحد',
99
+ procedureTitle: 'التفاصيل',
93
100
  fieldRequired: '{field} مطلوب',
94
101
  invalidEmail: 'أدخل بريدًا إلكترونيًا صالحًا',
95
102
  working: 'جارٍ العمل...',
@@ -105,6 +112,19 @@ const ar: LabelMap = {
105
112
  blockChooseTime: 'اختر وقتًا',
106
113
  blockProfessional: 'الموظف',
107
114
  blockPriceOffer: 'عرض سعر',
115
+ blockSubtotal: 'المجموع الفرعي',
116
+ blockDiscount: 'الخصم',
117
+ blockTax: 'الضريبة',
118
+ blockShipping: 'الشحن',
119
+ blockTotal: 'الإجمالي',
120
+ blockMinutes: '{count} دقيقة',
121
+ blockSave: 'حفظ',
122
+ blockAnyAvailable: 'أي موظف متاح',
123
+ blockWithProvider: 'مع {name}',
124
+ blockBookTime: 'احجز هذا الموعد',
125
+ blockValidUntil: 'صالح حتى {date}',
126
+ launcherOpen: 'فتح المحادثة',
127
+ launcherClose: 'إغلاق المحادثة',
108
128
  };
109
129
 
110
130
  export const BUNDLED_TRANSLATIONS: Record<string, LabelMap> = { ar };
@@ -50,7 +50,6 @@ interface MessagingHandle {
50
50
 
51
51
  function resolveMessaging(): MessagingHandle | null {
52
52
  try {
53
- // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
54
53
  const mod = require('@react-native-firebase/messaging');
55
54
 
56
55
  // Modular API — the only one in RNFB v26+ (the namespaced default export
@@ -36,7 +36,8 @@ import type {
36
36
  } from '../types';
37
37
  import { setOpenUrlHandler } from '../ui/utils/safe-url';
38
38
 
39
- const SDK_VERSION = '@repzo/widget-react-native@1.0.5';
39
+ /** Sent on every init; must match `package.json` (tests/version.test.js checks). */
40
+ const SDK_VERSION = '@repzo/widget-react-native@1.1.0';
40
41
 
41
42
  /** The platform this session reports — mobile sessions get the Mobile SDKs overrides. */
42
43
  function sessionPlatform(): 'ios' | 'android' | 'web' {
@@ -755,6 +756,7 @@ export class WidgetStore {
755
756
  this.userKeyLoaded = true;
756
757
  this.pendingAgentByConversation = {};
757
758
  this.seenMessageIds = [];
759
+ this.earlyStatuses.clear();
758
760
  this.visitorTypingActive = false;
759
761
  this.lastTypingPingAt = 0;
760
762
  this.launchCardOffered = false;
@@ -1071,12 +1073,6 @@ export class WidgetStore {
1071
1073
  return false;
1072
1074
  }
1073
1075
 
1074
- /**
1075
- * A teammate deleted a message: the visitor sees "This message was deleted"
1076
- * in its place (they can't delete anything themselves), and the list is
1077
- * refreshed so its preview stops showing the text. The id stays in
1078
- * `seenMessageIds`, so a replayed `message.new` can't restore the content.
1079
- */
1080
1076
  /**
1081
1077
  * A teammate opened the conversation: the visitor's messages are now Seen.
1082
1078
  * The ids are also remembered, because the event can arrive before the send
@@ -1086,9 +1082,15 @@ export class WidgetStore {
1086
1082
  const ids: string[] = Array.isArray(data?.messageIds) ? data.messageIds : [];
1087
1083
  const status: string | undefined = typeof data?.status === 'string' ? data.status : undefined;
1088
1084
  if (!status || ids.length === 0) return;
1089
- for (const id of ids) this.earlyStatuses.set(id, status);
1090
- if (this.earlyStatuses.size > SEEN_MESSAGE_LIMIT) {
1091
- this.earlyStatuses.delete(this.earlyStatuses.keys().next().value as string);
1085
+ for (const id of ids) {
1086
+ // Re-inserted, so a repeated id counts as newest when trimming.
1087
+ this.earlyStatuses.delete(id);
1088
+ this.earlyStatuses.set(id, status);
1089
+ }
1090
+ // One event can carry many ids: trim back to the cap, oldest first.
1091
+ for (const id of this.earlyStatuses.keys()) {
1092
+ if (this.earlyStatuses.size <= SEEN_MESSAGE_LIMIT) break;
1093
+ this.earlyStatuses.delete(id);
1092
1094
  }
1093
1095
  if (!this.isThreadOnScreen(data?.conversationId)) return;
1094
1096
  this.setState((current) => ({
@@ -1098,6 +1100,12 @@ export class WidgetStore {
1098
1100
  }));
1099
1101
  }
1100
1102
 
1103
+ /**
1104
+ * A teammate deleted a message: the visitor sees "This message was deleted"
1105
+ * in its place (they can't delete anything themselves), and the list is
1106
+ * refreshed so its preview stops showing the text. The id stays in
1107
+ * `seenMessageIds`, so a replayed `message.new` can't restore the content.
1108
+ */
1101
1109
  private handleDeletedMessage(data: any) {
1102
1110
  const messageId: string | undefined = data?.messageId;
1103
1111
  if (!messageId) return;
@@ -1263,7 +1271,7 @@ export class WidgetStore {
1263
1271
  body: null,
1264
1272
  bodyPlain: data.bodyPlain || 'Conversation updated',
1265
1273
  attachments: null,
1266
- metadata: data.metadata || null,
1274
+ metadata: this.hydrateMetadata(data.metadata || null),
1267
1275
  status: 'delivered',
1268
1276
  createdAt: data.createdAt || new Date().toISOString(),
1269
1277
  };
@@ -1667,10 +1675,35 @@ export class WidgetStore {
1667
1675
  .reverse();
1668
1676
  }
1669
1677
 
1670
- /** The "joined" line's avatar can be a server-relative media path. */
1678
+ /**
1679
+ * Media URLs inside metadata (the "joined" line's avatar, product photos in
1680
+ * agent blocks) can be server-relative paths, and are remote content: like
1681
+ * every other media URL they resolve against `baseUrl`, and anything that
1682
+ * isn't http(s) is dropped before it reaches an `<Image>`.
1683
+ */
1671
1684
  private hydrateMetadata(metadata: Message['metadata']): Message['metadata'] {
1672
- if (!metadata || typeof metadata.agentAvatar !== 'string') return metadata ?? null;
1673
- return { ...metadata, agentAvatar: this.client.resolveUrl(metadata.agentAvatar) };
1685
+ if (!metadata) return null;
1686
+ let next = metadata;
1687
+ if (typeof metadata.agentAvatar === 'string') {
1688
+ next = { ...next, agentAvatar: this.client.resolveUrl(metadata.agentAvatar) };
1689
+ }
1690
+ if (Array.isArray(metadata.agentUiBlocks)) {
1691
+ next = {
1692
+ ...next,
1693
+ agentUiBlocks: metadata.agentUiBlocks.map((block: unknown) => this.hydrateBlockMedia(block)),
1694
+ };
1695
+ }
1696
+ return next;
1697
+ }
1698
+
1699
+ private hydrateBlockMedia(block: unknown): unknown {
1700
+ const candidate = block as { type?: unknown; product?: { imageUrl?: unknown } } | null;
1701
+ const imageUrl = candidate?.product?.imageUrl;
1702
+ if (candidate?.type !== 'product_service_card' || typeof imageUrl !== 'string') return block;
1703
+ return {
1704
+ ...candidate,
1705
+ product: { ...candidate.product, imageUrl: this.client.resolveUrl(imageUrl) ?? undefined },
1706
+ };
1674
1707
  }
1675
1708
 
1676
1709
  /**
@@ -1967,11 +2000,6 @@ function isLocalMessage(message: Message): boolean {
1967
2000
  return message.id.startsWith('temp-');
1968
2001
  }
1969
2002
 
1970
- /**
1971
- * A sent visitor message keeps its local previews (a `file://` URI, or no URL
1972
- * at all) until the server copy shows up — the stream echo or a history page.
1973
- * Its attachments, the only ones that can be opened, then replace them.
1974
- */
1975
2003
  /** A "Seen" missed while the stream was down shows up on the next re-read. */
1976
2004
  function adoptServerStatuses(messages: Message[], server: Message[]): Message[] {
1977
2005
  const seen = new Set(server.filter((message) => message.status === 'read').map((message) => message.id));
@@ -2006,6 +2034,11 @@ function adoptServerDeletions(messages: Message[], server: Message[]): Message[]
2006
2034
  return changed ? next : messages;
2007
2035
  }
2008
2036
 
2037
+ /**
2038
+ * A sent visitor message keeps its local previews (a `file://` URI, or no URL
2039
+ * at all) until the server copy shows up — the stream echo or a history page.
2040
+ * Its attachments, the only ones that can be opened, then replace them.
2041
+ */
2009
2042
  function adoptServerAttachments(messages: Message[], server: Message[]): Message[] {
2010
2043
  const serverCopies = new Map(
2011
2044
  server.filter((message) => message.attachments?.length).map((message) => [message.id, message])
package/src/storage.ts CHANGED
@@ -44,7 +44,6 @@ function createMemoryAdapter(): RepzoWidgetStorageAdapter {
44
44
 
45
45
  function resolveAsyncStorage(): RepzoWidgetStorageAdapter | null {
46
46
  try {
47
- // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
48
47
  const mod = require('@react-native-async-storage/async-storage');
49
48
  const asyncStorage = mod?.default ?? mod;
50
49
  if (!asyncStorage?.getItem) return null;
@@ -101,10 +100,27 @@ export const Storage = {
101
100
 
102
101
  let secureAdapter: RepzoWidgetStorageAdapter | null = null;
103
102
 
103
+ let warnedPlaintext = false;
104
+
104
105
  export function setSecureStorageAdapter(adapter: RepzoWidgetStorageAdapter | null): void {
105
106
  secureAdapter = adapter;
106
107
  }
107
108
 
109
+ /**
110
+ * Development builds only, once per run: the bearer token is about to land on
111
+ * disk unencrypted. The in-memory fallback writes nothing, so it stays quiet.
112
+ */
113
+ function warnPlaintextOnce(): void {
114
+ if (warnedPlaintext || typeof __DEV__ === 'undefined' || !__DEV__) return;
115
+ if (activeAdapter() === memoryAdapter) return;
116
+ warnedPlaintext = true;
117
+ console.warn(
118
+ '[repzo-widget] no secure storage adapter is set, so the visitor token is stored in plain ' +
119
+ 'text. Call RepzoWidget.setSecureStorageAdapter() before boot() — see "Secure token ' +
120
+ 'storage" in the README.'
121
+ );
122
+ }
123
+
108
124
  export const SecureStorage = {
109
125
  async getItem(key: string): Promise<string | null> {
110
126
  if (!secureAdapter) return Storage.getItem(key);
@@ -126,6 +142,7 @@ export const SecureStorage = {
126
142
 
127
143
  async setItem(key: string, value: string): Promise<void> {
128
144
  if (!secureAdapter) {
145
+ warnPlaintextOnce();
129
146
  await Storage.setItem(key, value);
130
147
  return;
131
148
  }
package/src/types.ts CHANGED
@@ -164,10 +164,18 @@ export interface WidgetUser {
164
164
  /**
165
165
  * The user's account (company) in your app. Matched by `id`, so a renamed
166
166
  * account updates the same CRM account instead of creating another.
167
+ * @since 1.0.8
167
168
  */
168
169
  account?: WidgetUserAccount;
170
+ /**
171
+ * When they signed up in your app — a Date, unix seconds/ms, or an ISO
172
+ * string. Shown as "Signed up" in the inbox.
173
+ * @since 1.0.9
174
+ */
175
+ signedUpAt?: Date | number | string;
169
176
  }
170
177
 
178
+ /** The user's account (company) in your app, sent as `WidgetUser.account`. @since 1.0.8 */
171
179
  export interface WidgetUserAccount {
172
180
  /** Your app's stable account id — required. */
173
181
  id: string;
@@ -488,13 +496,6 @@ export interface RepzoWidgetStorageAdapter {
488
496
  removeItem(key: string): Promise<void> | void;
489
497
  }
490
498
 
491
- /**
492
- * `RepzoWidgetError.code`. The last two come from the server's 403 body and
493
- * are terminal — the SDK stops realtime and does not retry on its own:
494
- * - `messenger_api_disabled` — the channel's Messenger API is switched off.
495
- * - `identity_verification_failed` — `userHash` missing/wrong while the
496
- * channel enforces identity verification.
497
- */
498
499
  /** A bad-rating reason on the rating card, labelled per language. */
499
500
  export interface RatingReason {
500
501
  id: string;
@@ -514,6 +515,13 @@ export interface ConversationRatingResult {
514
515
  locked: boolean;
515
516
  }
516
517
 
518
+ /**
519
+ * `RepzoWidgetError.code`. The last two come from the server's 403 body and
520
+ * are terminal — the SDK stops realtime and does not retry on its own:
521
+ * - `messenger_api_disabled` — the channel's Messenger API is switched off.
522
+ * - `identity_verification_failed` — `userHash` missing/wrong while the
523
+ * channel enforces identity verification.
524
+ */
517
525
  export type RepzoWidgetErrorCode =
518
526
  | 'network'
519
527
  | 'http'
@@ -39,6 +39,7 @@ import { useKeyboardOverlap } from './hooks/use-keyboard-overlap';
39
39
  import { TopInsetContext } from './hooks/use-top-inset';
40
40
  import { useWidgetInsets } from './hooks/use-widget-insets';
41
41
  import { ChatView } from './screens/chat/ChatView';
42
+ import type { ChatLabels } from './screens/chat/ChatView.types';
42
43
  import { ConversationsList } from './screens/conversations/ConversationsList';
43
44
  import { HomeView, resolveHeaderTextColor } from './screens/home/HomeView';
44
45
  import { PreChatForm } from './screens/pre-chat/PreChatForm';
@@ -135,6 +136,17 @@ export function RepzoWidgetProvider({
135
136
  professional: labels.blockProfessional,
136
137
  priceOffer: labels.blockPriceOffer,
137
138
  working: labels.working,
139
+ subtotal: labels.blockSubtotal,
140
+ discount: labels.blockDiscount,
141
+ tax: labels.blockTax,
142
+ shipping: labels.blockShipping,
143
+ total: labels.blockTotal,
144
+ minutes: labels.blockMinutes,
145
+ save: labels.blockSave,
146
+ anyAvailable: labels.blockAnyAvailable,
147
+ withProvider: labels.blockWithProvider,
148
+ bookTime: labels.blockBookTime,
149
+ validUntil: labels.blockValidUntil,
138
150
  }),
139
151
  [labels]
140
152
  );
@@ -156,6 +168,62 @@ export function RepzoWidgetProvider({
156
168
  [labels]
157
169
  );
158
170
 
171
+ // Stable across renders so each message bubble only re-renders when its
172
+ // own message changes, not on every store update or keystroke.
173
+ const chatLabels = useMemo<ChatLabels>(
174
+ () => ({
175
+ back: labels.back,
176
+ loading: labels.loading,
177
+ loadEarlier: labels.loadEarlier,
178
+ emptyConversation: labels.emptyConversation,
179
+ messagePlaceholder: labels.messagePlaceholder,
180
+ failedToSend: labels.failedToSend,
181
+ thinking: labels.thinking,
182
+ team: labels.team,
183
+ statusOnline: labels.homeStatusOnline,
184
+ statusAway: labels.homeStatusAway,
185
+ close: labels.close,
186
+ today: labels.today,
187
+ yesterday: labels.yesterday,
188
+ conversationUpdated: labels.conversationUpdated,
189
+ messageDeleted: labels.messageDeleted,
190
+ messageSent: labels.messageSent,
191
+ messageSeen: labels.messageSeen,
192
+ waitingForTeammate: labels.waitingForTeammate,
193
+ joinedConversation: labels.joinedConversation,
194
+ procedureSend: labels.procedureSend,
195
+ procedureSent: labels.procedureSent,
196
+ procedureShareDetails: labels.procedureShareDetails,
197
+ procedureDetailsSent: labels.procedureDetailsSent,
198
+ procedureHide: labels.procedureHide,
199
+ procedureFieldCount: labels.procedureFieldCount,
200
+ procedureFieldCountOne: labels.procedureFieldCountOne,
201
+ procedureTitle: labels.procedureTitle,
202
+ procedureEmailPlaceholder: labels.procedureEmailPlaceholder,
203
+ procedurePhonePlaceholder: labels.procedurePhonePlaceholder,
204
+ blocks: blockLabels,
205
+ rating: {
206
+ question: labels.ratingQuestion,
207
+ reasonPrompt: labels.ratingReasonPrompt,
208
+ commentPlaceholder: labels.ratingCommentPlaceholder,
209
+ submit: labels.ratingSubmit,
210
+ done: labels.ratingDone,
211
+ thanks: labels.ratingThanks,
212
+ locked: labels.ratingLocked,
213
+ expired: labels.ratingExpired,
214
+ failed: labels.ratingFailed,
215
+ scale: [labels.rating1, labels.rating2, labels.rating3, labels.rating4, labels.rating5],
216
+ },
217
+ attach: attachLabels,
218
+ viewer: {
219
+ close: labels.close,
220
+ loadFailed: labels.imageLoadFailed,
221
+ openExternally: labels.openImageExternally,
222
+ },
223
+ }),
224
+ [labels, blockLabels, attachLabels]
225
+ );
226
+
159
227
  // Read per render: `boot({ attachments })` sets it outside React state.
160
228
  const attachmentSources = theme.attachmentsEnabled ? getAttachmentSources() : [];
161
229
 
@@ -176,6 +244,21 @@ export function RepzoWidgetProvider({
176
244
  [store]
177
245
  );
178
246
 
247
+ const handleIdentifyVisitor = useCallback(
248
+ (data: { email?: string; name?: string; phone?: string }) => store.identifyVisitor(data),
249
+ [store]
250
+ );
251
+ const handleRateConversation = useCallback(
252
+ (
253
+ messageId: string,
254
+ ratingId: string,
255
+ data: { rating: number; reason?: string; comment?: string }
256
+ ) => store.rateConversation(messageId, ratingId, data),
257
+ [store]
258
+ );
259
+ const handleLoadMore = useCallback(() => void store.loadMoreMessages(), [store]);
260
+ const handleTyping = useCallback((isTyping: boolean) => store.notifyTyping(isTyping), [store]);
261
+
179
262
  const handlePreChatSubmit = useCallback(
180
263
  async (data: Record<string, string>) => {
181
264
  try {
@@ -298,55 +381,14 @@ export function RepzoWidgetProvider({
298
381
  defaultLocale={state.config?.defaultLocale}
299
382
  prefillText={state.prefillText}
300
383
  offlineMessage={theme.offlineMessage}
301
- labels={{
302
- back: labels.back,
303
- loading: labels.loading,
304
- loadEarlier: labels.loadEarlier,
305
- emptyConversation: labels.emptyConversation,
306
- messagePlaceholder: labels.messagePlaceholder,
307
- failedToSend: labels.failedToSend,
308
- thinking: labels.thinking,
309
- team: labels.team,
310
- statusOnline: labels.homeStatusOnline,
311
- statusAway: labels.homeStatusAway,
312
- close: labels.close,
313
- today: labels.today,
314
- yesterday: labels.yesterday,
315
- conversationUpdated: labels.conversationUpdated,
316
- messageDeleted: labels.messageDeleted,
317
- messageSent: labels.messageSent,
318
- messageSeen: labels.messageSeen,
319
- waitingForTeammate: labels.waitingForTeammate,
320
- joinedConversation: labels.joinedConversation,
321
- procedureSend: labels.procedureSend,
322
- procedureSent: labels.procedureSent,
323
- blocks: blockLabels,
324
- rating: {
325
- question: labels.ratingQuestion,
326
- reasonPrompt: labels.ratingReasonPrompt,
327
- commentPlaceholder: labels.ratingCommentPlaceholder,
328
- submit: labels.ratingSubmit,
329
- done: labels.ratingDone,
330
- thanks: labels.ratingThanks,
331
- locked: labels.ratingLocked,
332
- expired: labels.ratingExpired,
333
- failed: labels.ratingFailed,
334
- scale: [labels.rating1, labels.rating2, labels.rating3, labels.rating4, labels.rating5],
335
- },
336
- attach: attachLabels,
337
- viewer: {
338
- close: labels.close,
339
- loadFailed: labels.imageLoadFailed,
340
- openExternally: labels.openImageExternally,
341
- },
342
- }}
384
+ labels={chatLabels}
343
385
  attachmentSources={attachmentSources}
344
386
  onPickAttachments={pickAttachments}
345
387
  onSendMessage={handleSendMessage}
346
- onIdentifyVisitor={(data) => store.identifyVisitor(data)}
347
- onRateConversation={(messageId, ratingId, data) => store.rateConversation(messageId, ratingId, data)}
348
- onLoadMore={() => store.loadMoreMessages()}
349
- onTyping={(isTyping) => store.notifyTyping(isTyping)}
388
+ onIdentifyVisitor={handleIdentifyVisitor}
389
+ onRateConversation={handleRateConversation}
390
+ onLoadMore={handleLoadMore}
391
+ onTyping={handleTyping}
350
392
  onBack={handleBack}
351
393
  onClose={handleClose}
352
394
  />