@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
@@ -8,6 +8,7 @@
8
8
  import { createContext, useContext, useState } from 'react';
9
9
  import { Image, Pressable, StyleSheet, Text, TextInput, View, type ViewStyle } from 'react-native';
10
10
 
11
+ import { formatLabel } from '../../i18n';
11
12
  import type { AgentUiAction, AgentUiBlock } from '../../types';
12
13
  import { fontSize, palette, radius, shadows, spacing, withAlpha } from '../styles/theme';
13
14
 
@@ -18,6 +19,20 @@ export interface AgentUiBlockLabels {
18
19
  professional: string;
19
20
  priceOffer: string;
20
21
  working: string;
22
+ subtotal: string;
23
+ discount: string;
24
+ tax: string;
25
+ shipping: string;
26
+ total: string;
27
+ /** `{count}` = minutes. */
28
+ minutes: string;
29
+ save: string;
30
+ anyAvailable: string;
31
+ /** `{name}` = the professional. */
32
+ withProvider: string;
33
+ bookTime: string;
34
+ /** `{date}` = the formatted date. */
35
+ validUntil: string;
21
36
  }
22
37
 
23
38
  const DEFAULT_BLOCK_LABELS: AgentUiBlockLabels = {
@@ -27,6 +42,17 @@ const DEFAULT_BLOCK_LABELS: AgentUiBlockLabels = {
27
42
  professional: 'Professional',
28
43
  priceOffer: 'Price offer',
29
44
  working: 'Working...',
45
+ subtotal: 'Subtotal',
46
+ discount: 'Discount',
47
+ tax: 'Tax',
48
+ shipping: 'Shipping',
49
+ total: 'Total',
50
+ minutes: '{count} min',
51
+ save: 'Save',
52
+ anyAvailable: 'Any available',
53
+ withProvider: 'With {name}',
54
+ bookTime: 'Book this time',
55
+ validUntil: 'Valid until {date}',
30
56
  };
31
57
 
32
58
  const BlockLabelContext = createContext<AgentUiBlockLabels>(DEFAULT_BLOCK_LABELS);
@@ -99,7 +125,7 @@ export function AgentUiBlocks({
99
125
  <View style={styles.row}>
100
126
  <Text style={styles.muted}>
101
127
  {block.product.durationMinutes
102
- ? `${block.product.durationMinutes} min`
128
+ ? formatLabel(labels.minutes, { count: block.product.durationMinutes })
103
129
  : block.product.unit || ''}
104
130
  </Text>
105
131
  <Text style={styles.price}>
@@ -182,23 +208,23 @@ export function AgentUiBlocks({
182
208
  ))}
183
209
  </View>
184
210
  <View style={styles.totals}>
185
- <MoneyRow label="Subtotal" value={formatMoney(block.subtotal, block.currency)} />
211
+ <MoneyRow label={labels.subtotal} value={formatMoney(block.subtotal, block.currency)} />
186
212
  {block.discount ? (
187
213
  <MoneyRow
188
- label="Discount"
214
+ label={labels.discount}
189
215
  value={`−${formatMoney(block.discount, block.currency)}`}
190
216
  />
191
217
  ) : null}
192
218
  {block.tax ? (
193
- <MoneyRow label="Tax" value={formatMoney(block.tax, block.currency)} />
219
+ <MoneyRow label={labels.tax} value={formatMoney(block.tax, block.currency)} />
194
220
  ) : null}
195
221
  {block.shipping ? (
196
222
  <MoneyRow
197
- label="Shipping"
223
+ label={labels.shipping}
198
224
  value={formatMoney(block.shipping, block.currency)}
199
225
  />
200
226
  ) : null}
201
- <MoneyRow label="Total" value={formatMoney(block.total, block.currency)} strong />
227
+ <MoneyRow label={labels.total} value={formatMoney(block.total, block.currency)} strong />
202
228
  </View>
203
229
  {block.action ? (
204
230
  <View style={styles.blockAction}>
@@ -314,7 +340,7 @@ export function AgentUiBlocks({
314
340
  hitSlop={8}>
315
341
  <Text
316
342
  style={[styles.linkButton, { color: checked ? color : palette.textSubtle }]}>
317
- Save
343
+ {labels.save}
318
344
  </Text>
319
345
  </Pressable>
320
346
  </View>
@@ -339,7 +365,7 @@ export function AgentUiBlocks({
339
365
  <View key={block.id} style={[styles.card, styles.cardPadded]}>
340
366
  <Text style={styles.title}>{labels.chooseTime}</Text>
341
367
  <Text style={styles.description}>
342
- {block.serviceName} · {block.durationMinutes} min
368
+ {block.serviceName} · {formatLabel(labels.minutes, { count: block.durationMinutes })}
343
369
  {block.timezone ? ` · ${block.timezone}` : ''}
344
370
  </Text>
345
371
 
@@ -351,7 +377,7 @@ export function AgentUiBlocks({
351
377
  selected={activeProviderId === 'any'}
352
378
  color={color}
353
379
  disabled={pending !== null}
354
- label="Any available"
380
+ label={labels.anyAvailable}
355
381
  onPress={() =>
356
382
  activeProviderId !== 'any' &&
357
383
  act({
@@ -425,7 +451,7 @@ export function AgentUiBlocks({
425
451
 
426
452
  {selectedProvider ? (
427
453
  <Text style={[styles.muted, styles.blockAction]}>
428
- With {selectedProvider.name}
454
+ {formatLabel(labels.withProvider, { name: selectedProvider.name })}
429
455
  </Text>
430
456
  ) : null}
431
457
  <View style={styles.blockAction}>
@@ -453,7 +479,7 @@ export function AgentUiBlocks({
453
479
  },
454
480
  })
455
481
  }>
456
- Book this time
482
+ {labels.bookTime}
457
483
  </ActionButton>
458
484
  </View>
459
485
  </View>
@@ -477,7 +503,9 @@ export function AgentUiBlocks({
477
503
  <Text style={styles.amount}>{formatMoney(block.amount, block.currency)}</Text>
478
504
  {block.validUntil ? (
479
505
  <Text style={styles.muted}>
480
- Valid until {formatDate(block.validUntil.slice(0, 10))}
506
+ {formatLabel(labels.validUntil, {
507
+ date: formatDate(block.validUntil.slice(0, 10)),
508
+ })}
481
509
  </Text>
482
510
  ) : null}
483
511
  {publicActions.map((action) => (
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { Pressable, StyleSheet, Text, View } from 'react-native';
11
11
 
12
+ import { createTranslator, DEFAULT_LABELS } from '../../i18n';
12
13
  import { useWidgetState } from '../../state/use-widget-state';
13
14
  import { controller } from '../../controller';
14
15
  import { ChatIcon, ChevronIcon } from './icons';
@@ -47,6 +48,10 @@ export function Launcher({
47
48
  const inset = Math.max(horizontalPadding ?? config?.launcherHorizontalPadding ?? 20, 20);
48
49
  const text = config?.bubbleText;
49
50
  const showText = !!text && !state.isOpen;
51
+ const translate = createTranslator(config, state.locale);
52
+ const accessibilityLabel = state.isOpen
53
+ ? translate('launcherClose', DEFAULT_LABELS.launcherClose)
54
+ : translate('launcherOpen', DEFAULT_LABELS.launcherOpen);
50
55
 
51
56
  return (
52
57
  <View
@@ -55,7 +60,7 @@ export function Launcher({
55
60
  <Pressable
56
61
  onPress={() => controller.store.toggle()}
57
62
  accessibilityRole="button"
58
- accessibilityLabel={state.isOpen ? 'Close chat' : 'Open chat'}
63
+ accessibilityLabel={accessibilityLabel}
59
64
  style={({ pressed }) => [
60
65
  styles.bubble,
61
66
  showText ? styles.bubbleWithText : styles.bubbleRound,
@@ -7,7 +7,7 @@
7
7
  * (including bare URLs, linkified the same way the web widget does).
8
8
  */
9
9
 
10
- import { Fragment } from 'react';
10
+ import { Fragment, useMemo } from 'react';
11
11
  import { StyleSheet, Text, View } from 'react-native';
12
12
 
13
13
  import { fontSize, palette, radius, spacing } from '../styles/theme';
@@ -58,6 +58,36 @@ function linkifyPlainText(text: string): string {
58
58
  });
59
59
  }
60
60
 
61
+ /** Host of an absolute web URL, lower-cased and without `www.`; null otherwise. */
62
+ function targetHost(href: string): string | null {
63
+ const match = /^https?:\/\/(?:[^@/?#\s]*@)?([^/?#:\s]+)/i.exec(href.trim());
64
+ return match ? match[1]!.toLowerCase().replace(/^www\./, '') : null;
65
+ }
66
+
67
+ const ADDRESS_LABEL =
68
+ /^(?:https?:\/\/)?(?:www\.)?((?:[a-z0-9-]+\.)+[a-z]{2,24})(?::\d+)?(?:[/?#]\S*)?$/i;
69
+
70
+ /**
71
+ * The text a link shows. Agent and AI replies are remote content, and
72
+ * `[https://yourbank.com](https://evil.example)` would read as one site while
73
+ * opening another: when the label itself reads as a web address on a
74
+ * different host than the target, the real target is shown instead. Ordinary
75
+ * labels ("Book here", `report.pdf`) are left alone.
76
+ */
77
+ export function linkLabel(label: string, href: string): string {
78
+ const text = label.trim();
79
+ if (!text) return href;
80
+ const looksLikeScheme = /^(?:[a-z][a-z0-9+.-]*:\/\/|www\.)/i.test(text);
81
+ const address = ADDRESS_LABEL.exec(text);
82
+ if (!address && !looksLikeScheme) return label;
83
+ if (address && !looksLikeScheme && !text.includes('/')) {
84
+ const extension = address[1]!.split('.').pop()?.toLowerCase() ?? '';
85
+ if (NON_LINK_EXTENSIONS.has(extension)) return label;
86
+ }
87
+ const shown = address ? address[1]!.toLowerCase().replace(/^www\./, '') : null;
88
+ return shown && shown === targetHost(href) ? label : href;
89
+ }
90
+
61
91
  /** Turn bare URLs into markdown links while leaving code and existing links alone. */
62
92
  export function linkifyMarkdown(text: string): string {
63
93
  if (!text) return text;
@@ -108,8 +138,8 @@ function InlineMarkdown({ content, color }: { content: string; color: string })
108
138
 
109
139
  const link = /^!?\[([^\]]*)\]\(([^)]*)\)$/.exec(part);
110
140
  if (link) {
111
- const label = link[1] || link[2] || '';
112
141
  const href = link[2] || '';
142
+ const label = linkLabel(link[1] || '', href);
113
143
  return (
114
144
  <Text
115
145
  key={key}
@@ -134,7 +164,8 @@ interface MarkdownProps {
134
164
  }
135
165
 
136
166
  export function Markdown({ content, color, linkColor }: MarkdownProps) {
137
- const blocks = parseBlocks(linkifyMarkdown(content));
167
+ // Parsed once per body: a thread re-renders far more often than it changes.
168
+ const blocks = useMemo(() => parseBlocks(linkifyMarkdown(content)), [content]);
138
169
  const effectiveLinkColor = linkColor ?? color;
139
170
 
140
171
  return (
@@ -104,8 +104,10 @@ export function RatingCard({
104
104
  const [comment, setComment] = useState<string | null>(initialComment);
105
105
  const [draft, setDraft] = useState('');
106
106
  // The comment is optional: "Done" with nothing typed just closes the box —
107
- // the face (and reason) are already saved.
108
- const [finished, setFinished] = useState(false);
107
+ // the face (and reason) are already saved. A card that opens with a saved
108
+ // rating starts closed too, so "Done" sticks when the thread is reopened;
109
+ // tapping the chosen face opens the box again.
110
+ const [finished, setFinished] = useState(initialRating != null);
109
111
  const [pending, setPending] = useState(false);
110
112
  const [closedReason, setClosedReason] = useState<'locked' | 'expired' | null>(initialComment ? 'locked' : null);
111
113
  const [failed, setFailed] = useState(false);
@@ -124,6 +126,7 @@ export function RatingCard({
124
126
  setComment(result.comment);
125
127
  if (result.locked) setClosedReason('locked');
126
128
  setDraft('');
129
+ setFinished(false);
127
130
  } catch (error) {
128
131
  const status = (error as { status?: number }).status;
129
132
  if (status === 409) setClosedReason('locked');
@@ -162,6 +165,7 @@ export function RatingCard({
162
165
  hitSlop={4}
163
166
  onPress={() => {
164
167
  if (!selected) void submit({ rating: entry.value });
168
+ else setFinished(false);
165
169
  }}
166
170
  style={({ pressed }) => [
167
171
  styles.face,
@@ -25,7 +25,6 @@ const FALLBACK_INSETS: WidgetInsets = {
25
25
 
26
26
  let insetsHook: (() => WidgetInsets) | null = null;
27
27
  try {
28
- // eslint-disable-next-line @typescript-eslint/no-var-requires
29
28
  const mod = require('react-native-safe-area-context');
30
29
  if (typeof mod?.useSafeAreaInsets === 'function') insetsHook = mod.useSafeAreaInsets;
31
30
  } catch {
@@ -8,7 +8,7 @@
8
8
  * `attachments/pickers.ts`).
9
9
  */
10
10
 
11
- import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
11
+ import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
12
12
  import {
13
13
  Animated,
14
14
  Easing,
@@ -33,6 +33,7 @@ import {
33
33
  type AttachmentProblem,
34
34
  type DraftAttachment,
35
35
  } from '../../../attachments/pickers';
36
+ import { formatLabel } from '../../../i18n';
36
37
  import type {
37
38
  AgentUiAction,
38
39
  AgentProcedureForm,
@@ -315,20 +316,23 @@ export function ChatView({
315
316
  [onIdentifyVisitor, onSendMessage, scrollToBottom]
316
317
  );
317
318
 
319
+ const handleSubmitProcedureForm = useCallback(
320
+ (body: string) => void onSendMessage(body).catch(() => {}),
321
+ [onSendMessage]
322
+ );
323
+
324
+ // Recomputed only when the list changes — never on a keystroke.
325
+ const thread = useMemo(() => deriveThread(messages), [messages]);
326
+ const locales = useMemo(() => [locale, defaultLocale], [locale, defaultLocale]);
327
+
318
328
  const headerTextColor = contrastingTextColor(color);
319
329
  const onDarkHeader = headerTextColor === palette.white;
320
330
  const isEmptyThread = !loading && messages.length === 0;
321
331
  const agentIsActive = agentOnline ?? isOnline;
322
332
  // "Waiting for a teammate" until someone replies: no teammate in the header
323
333
  // and the visitor spoke last (bot replies before that still count as waiting).
324
- const lastChatMessage = [...messages]
325
- .reverse()
326
- .find((message) => (message.messageType ?? 'message') === 'message' && !message.deletedAt);
327
- const awaitingTeammate = !agentName && !agentTyping && lastChatMessage?.direction === 'inbound';
328
- // "Sent" / "Seen" goes under the visitor's latest message only.
329
- const lastVisitorMessageId = [...messages]
330
- .reverse()
331
- .find((message) => message.direction === 'inbound' && (message.messageType ?? 'message') === 'message')?.id;
334
+ const awaitingTeammate =
335
+ !agentName && !agentTyping && thread.lastChatMessage?.direction === 'inbound';
332
336
  // Two stops off the brand color: flat fill looked like an unstyled block.
333
337
  // A non-hex brand color falls through mixHex unchanged and renders solid.
334
338
  const topInset = useTopInset();
@@ -493,21 +497,25 @@ export function ChatView({
493
497
  labels={labels}
494
498
  onOpenImage={setViewerImage}
495
499
  onRateConversation={onRateConversation}
496
- locales={[locale, defaultLocale]}
500
+ locales={locales}
497
501
  senderLabel={
498
502
  isAgentMessage && (startsRun || startsDay)
499
503
  ? message.senderName || agentName || labels.team
500
504
  : null
501
505
  }
502
506
  timeLabel={formatClockTime(message.createdAt, locale)}
503
- receiptLabel={message.id === lastVisitorMessageId ? receiptLabel(message, labels) : undefined}
507
+ receiptLabel={
508
+ message.id === thread.lastVisitorMessageId
509
+ ? receiptLabel(message, labels)
510
+ : undefined
511
+ }
504
512
  onSubmitProcedureForm={
505
- shouldShowProcedureForm(messages, message)
506
- ? (body) => void onSendMessage(body).catch(() => {})
513
+ message.id === thread.procedureFormMessageId
514
+ ? handleSubmitProcedureForm
507
515
  : undefined
508
516
  }
509
517
  onAgentUiAction={
510
- shouldShowAgentUiBlocks(messages, message) ? handleAgentUiAction : undefined
518
+ thread.agentBlocksShown.has(message.id) ? handleAgentUiAction : undefined
511
519
  }
512
520
  />
513
521
  </WidgetErrorBoundary>
@@ -635,7 +643,11 @@ export function ChatView({
635
643
  );
636
644
  }
637
645
 
638
- function MessageBubble({
646
+ /**
647
+ * Memoized: the composer re-renders the screen on every keystroke, and every
648
+ * prop here is stable unless this message itself changed.
649
+ */
650
+ const MessageBubble = memo(function MessageBubble({
639
651
  message,
640
652
  color,
641
653
  labels,
@@ -795,7 +807,7 @@ function MessageBubble({
795
807
  ) : null}
796
808
  </View>
797
809
  );
798
- }
810
+ });
799
811
 
800
812
  /** Sent once the server has it, Seen once a teammate opened the conversation. */
801
813
  function receiptLabel(message: Message, labels: ChatLabels): string | undefined {
@@ -900,17 +912,21 @@ function ProcedureFieldForm({
900
912
  style={[styles.procedureToggle, { backgroundColor: expanded ? palette.surface : color }]}>
901
913
  <Text
902
914
  style={[styles.procedureToggleLabel, { color: expanded ? palette.text : palette.white }]}>
903
- {submitted ? 'Details sent' : 'Share details'}
915
+ {submitted ? labels.procedureDetailsSent : labels.procedureShareDetails}
904
916
  </Text>
905
917
  <Text
906
918
  style={[styles.procedureToggleHint, { color: expanded ? palette.text : palette.white }]}>
907
- {expanded ? 'Hide' : `${form.fields.length} field${form.fields.length === 1 ? '' : 's'}`}
919
+ {expanded
920
+ ? labels.procedureHide
921
+ : form.fields.length === 1
922
+ ? labels.procedureFieldCountOne
923
+ : formatLabel(labels.procedureFieldCount, { count: form.fields.length })}
908
924
  </Text>
909
925
  </Pressable>
910
926
 
911
927
  {expanded ? (
912
928
  <>
913
- <Text style={styles.procedureTitle}>{form.procedureName || 'Details'}</Text>
929
+ <Text style={styles.procedureTitle}>{form.procedureName || labels.procedureTitle}</Text>
914
930
  <View style={styles.procedureFields}>
915
931
  {form.fields.map((field) => (
916
932
  <View key={field.name}>
@@ -923,7 +939,7 @@ function ProcedureFieldForm({
923
939
  onChangeText={(text) =>
924
940
  setValues((current) => ({ ...current, [field.name]: text }))
925
941
  }
926
- placeholder={placeholderForField(field)}
942
+ placeholder={placeholderForField(field, labels)}
927
943
  placeholderTextColor={palette.textSubtle}
928
944
  editable={!submitted}
929
945
  keyboardType={
@@ -999,9 +1015,9 @@ function TypingDot({ index, reduceMotion }: { index: number; reduceMotion: boole
999
1015
  return <Animated.View style={[styles.typingDot, motionStyle]} />;
1000
1016
  }
1001
1017
 
1002
- function placeholderForField(field: AgentProcedureForm['fields'][number]) {
1003
- if (field.type === 'email') return 'name@example.com';
1004
- if (field.type === 'phone') return '+962 7...';
1018
+ function placeholderForField(field: AgentProcedureForm['fields'][number], labels: ChatLabels) {
1019
+ if (field.type === 'email') return labels.procedureEmailPlaceholder;
1020
+ if (field.type === 'phone') return labels.procedurePhonePlaceholder;
1005
1021
  if (field.type === 'date' || field.type === 'time') return '';
1006
1022
  return field.label;
1007
1023
  }
@@ -1010,24 +1026,43 @@ function stringPayload(value: unknown) {
1010
1026
  return typeof value === 'string' && value.trim() ? value.trim() : undefined;
1011
1027
  }
1012
1028
 
1013
- function shouldShowProcedureForm(messages: Message[], message: Message) {
1014
- if (message.direction === 'inbound' || !getProcedureForm(message.metadata)) return false;
1015
- return messages.findIndex((candidate) => candidate.id === message.id) === messages.length - 1;
1016
- }
1029
+ /**
1030
+ * Everything the thread derives from the whole list, in one backwards pass.
1031
+ * (Per-message lookups made this O(n²) on every render, keystrokes included.)
1032
+ */
1033
+ function deriveThread(messages: Message[]) {
1034
+ /** Messages whose agent blocks stay actionable. */
1035
+ const agentBlocksShown = new Set<string>();
1036
+ const laterSlotPickerIds = new Set<string>();
1037
+ let lastChatMessage: Message | undefined;
1038
+ let lastVisitorMessageId: string | undefined;
1039
+
1040
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
1041
+ const message = messages[index]!;
1042
+ const isChat = (message.messageType ?? 'message') === 'message';
1043
+ if (!lastChatMessage && isChat && !message.deletedAt) lastChatMessage = message;
1044
+ // "Sent" / "Seen" goes under the visitor's latest message only.
1045
+ if (!lastVisitorMessageId && isChat && message.direction === 'inbound') {
1046
+ lastVisitorMessageId = message.id;
1047
+ }
1017
1048
 
1018
- /** Hide superseded slot pickers so only the newest one stays actionable. */
1019
- function shouldShowAgentUiBlocks(messages: Message[], message: Message) {
1020
- const blocks = getAgentUiBlocks(message.metadata);
1021
- if (blocks.length === 0) return false;
1022
- if (!blocks.every((block) => block.type === 'appointment_slot_picker')) return true;
1023
-
1024
- const messageIndex = messages.findIndex((candidate) => candidate.id === message.id);
1025
- const laterBlockIds = new Set(
1026
- messages
1027
- .slice(messageIndex + 1)
1028
- .flatMap((candidate) => getAgentUiBlocks(candidate.metadata))
1029
- .filter((block) => block.type === 'appointment_slot_picker')
1030
- .map((block) => block.id)
1031
- );
1032
- return blocks.some((block) => !laterBlockIds.has(block.id));
1049
+ const blocks = getAgentUiBlocks(message.metadata);
1050
+ if (blocks.length === 0) continue;
1051
+ // A slot picker a later message re-sent is superseded: only the newest
1052
+ // one stays actionable. Any other block always does.
1053
+ const onlySlotPickers = blocks.every((block) => block.type === 'appointment_slot_picker');
1054
+ if (!onlySlotPickers || blocks.some((block) => !laterSlotPickerIds.has(block.id))) {
1055
+ agentBlocksShown.add(message.id);
1056
+ }
1057
+ blocks.forEach((block) => {
1058
+ if (block.type === 'appointment_slot_picker') laterSlotPickerIds.add(block.id);
1059
+ });
1060
+ }
1061
+
1062
+ // A procedure form only while it is the newest message in the thread.
1063
+ const last = messages[messages.length - 1];
1064
+ const procedureFormMessageId =
1065
+ last && last.direction !== 'inbound' && getProcedureForm(last.metadata) ? last.id : null;
1066
+
1067
+ return { agentBlocksShown, lastChatMessage, lastVisitorMessageId, procedureFormMessageId };
1033
1068
  }
@@ -35,6 +35,16 @@ export interface ChatLabels {
35
35
  joinedConversation: string;
36
36
  procedureSend: string;
37
37
  procedureSent: string;
38
+ procedureShareDetails: string;
39
+ procedureDetailsSent: string;
40
+ procedureHide: string;
41
+ /** `{count}` = number of fields (2 or more). */
42
+ procedureFieldCount: string;
43
+ procedureFieldCountOne: string;
44
+ /** Shown when the procedure has no name. */
45
+ procedureTitle: string;
46
+ procedureEmailPlaceholder: string;
47
+ procedurePhonePlaceholder: string;
38
48
  blocks: AgentUiBlockLabels;
39
49
  rating: RatingCardLabels;
40
50
  attach: AttachLabels;
@@ -14,10 +14,18 @@ const ENTITIES: Record<string, string> = {
14
14
  '&nbsp;': ' ',
15
15
  };
16
16
 
17
+ /** `&#128512;` / `&#x1F600;` → 😀. Out-of-range or surrogate code points stay as written. */
18
+ function decodeNumericEntity(match: string, decimal?: string, hex?: string): string {
19
+ const code = decimal !== undefined ? Number(decimal) : parseInt(hex ?? '', 16);
20
+ const valid =
21
+ Number.isInteger(code) && code > 0 && code <= 0x10ffff && (code < 0xd800 || code > 0xdfff);
22
+ return valid ? String.fromCodePoint(code) : match;
23
+ }
24
+
17
25
  function decodeEntities(text: string): string {
18
26
  return text
19
27
  .replace(/&(amp|lt|gt|quot|#39|apos|nbsp);/g, (match) => ENTITIES[match] ?? match)
20
- .replace(/&#(\d+);/g, (_match, code) => String.fromCharCode(Number(code)));
28
+ .replace(/&#(?:(\d{1,7})|[xX]([0-9a-fA-F]{1,6}));/g, decodeNumericEntity);
21
29
  }
22
30
 
23
31
  /** Block-level tags become newlines so paragraphs survive the strip. */
@@ -29,7 +29,6 @@ export interface NativeGradientProps {
29
29
  let nativeGradient: ComponentType<NativeGradientProps> | null = null;
30
30
 
31
31
  try {
32
- // eslint-disable-next-line @typescript-eslint/no-var-requires
33
32
  const mod = require('react-native-linear-gradient');
34
33
  const component = mod?.default ?? mod?.LinearGradient;
35
34
  if (component) nativeGradient = component as ComponentType<NativeGradientProps>;