@repzo/widget-react-native 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +92 -3
  3. package/lib/commonjs/api/client.js +35 -0
  4. package/lib/commonjs/api/client.js.map +1 -1
  5. package/lib/commonjs/i18n.js +14 -0
  6. package/lib/commonjs/i18n.js.map +1 -1
  7. package/lib/commonjs/index.js +2 -1
  8. package/lib/commonjs/index.js.map +1 -1
  9. package/lib/commonjs/locales.js +13 -0
  10. package/lib/commonjs/locales.js.map +1 -1
  11. package/lib/commonjs/push/push-notifications.js +65 -8
  12. package/lib/commonjs/push/push-notifications.js.map +1 -1
  13. package/lib/commonjs/state/widget-store.js +451 -7
  14. package/lib/commonjs/state/widget-store.js.map +1 -1
  15. package/lib/commonjs/types.js +15 -0
  16. package/lib/commonjs/types.js.map +1 -1
  17. package/lib/commonjs/ui/WidgetProvider.js +64 -2
  18. package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
  19. package/lib/commonjs/ui/components/Post.js +1241 -0
  20. package/lib/commonjs/ui/components/Post.js.map +1 -0
  21. package/lib/commonjs/ui/utils/html.js +1 -0
  22. package/lib/commonjs/ui/utils/html.js.map +1 -1
  23. package/lib/commonjs/ui/utils/post-html.js +276 -0
  24. package/lib/commonjs/ui/utils/post-html.js.map +1 -0
  25. package/lib/commonjs/ui/utils/safe-url.js +16 -0
  26. package/lib/commonjs/ui/utils/safe-url.js.map +1 -1
  27. package/lib/module/api/client.js +35 -0
  28. package/lib/module/api/client.js.map +1 -1
  29. package/lib/module/i18n.js +14 -0
  30. package/lib/module/i18n.js.map +1 -1
  31. package/lib/module/index.js +2 -1
  32. package/lib/module/index.js.map +1 -1
  33. package/lib/module/locales.js +13 -0
  34. package/lib/module/locales.js.map +1 -1
  35. package/lib/module/push/push-notifications.js +65 -8
  36. package/lib/module/push/push-notifications.js.map +1 -1
  37. package/lib/module/state/widget-store.js +450 -6
  38. package/lib/module/state/widget-store.js.map +1 -1
  39. package/lib/module/types.js +15 -0
  40. package/lib/module/types.js.map +1 -1
  41. package/lib/module/ui/WidgetProvider.js +65 -3
  42. package/lib/module/ui/WidgetProvider.js.map +1 -1
  43. package/lib/module/ui/components/Post.js +1236 -0
  44. package/lib/module/ui/components/Post.js.map +1 -0
  45. package/lib/module/ui/utils/html.js +1 -1
  46. package/lib/module/ui/utils/html.js.map +1 -1
  47. package/lib/module/ui/utils/post-html.js +271 -0
  48. package/lib/module/ui/utils/post-html.js.map +1 -0
  49. package/lib/module/ui/utils/safe-url.js +15 -0
  50. package/lib/module/ui/utils/safe-url.js.map +1 -1
  51. package/lib/typescript/commonjs/api/client.d.ts +21 -1
  52. package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/i18n.d.ts +13 -0
  54. package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/index.d.ts +14 -1
  56. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  57. package/lib/typescript/commonjs/locales.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/push/push-notifications.d.ts +26 -2
  59. package/lib/typescript/commonjs/push/push-notifications.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/state/widget-store.d.ts +84 -1
  61. package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/types.d.ts +77 -0
  63. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
  65. package/lib/typescript/commonjs/ui/components/Post.d.ts +69 -0
  66. package/lib/typescript/commonjs/ui/components/Post.d.ts.map +1 -0
  67. package/lib/typescript/commonjs/ui/utils/html.d.ts +1 -0
  68. package/lib/typescript/commonjs/ui/utils/html.d.ts.map +1 -1
  69. package/lib/typescript/commonjs/ui/utils/post-html.d.ts +54 -0
  70. package/lib/typescript/commonjs/ui/utils/post-html.d.ts.map +1 -0
  71. package/lib/typescript/commonjs/ui/utils/safe-url.d.ts +6 -0
  72. package/lib/typescript/commonjs/ui/utils/safe-url.d.ts.map +1 -1
  73. package/lib/typescript/module/api/client.d.ts +21 -1
  74. package/lib/typescript/module/api/client.d.ts.map +1 -1
  75. package/lib/typescript/module/i18n.d.ts +13 -0
  76. package/lib/typescript/module/i18n.d.ts.map +1 -1
  77. package/lib/typescript/module/index.d.ts +14 -1
  78. package/lib/typescript/module/index.d.ts.map +1 -1
  79. package/lib/typescript/module/locales.d.ts.map +1 -1
  80. package/lib/typescript/module/push/push-notifications.d.ts +26 -2
  81. package/lib/typescript/module/push/push-notifications.d.ts.map +1 -1
  82. package/lib/typescript/module/state/widget-store.d.ts +84 -1
  83. package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
  84. package/lib/typescript/module/types.d.ts +77 -0
  85. package/lib/typescript/module/types.d.ts.map +1 -1
  86. package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
  87. package/lib/typescript/module/ui/components/Post.d.ts +69 -0
  88. package/lib/typescript/module/ui/components/Post.d.ts.map +1 -0
  89. package/lib/typescript/module/ui/utils/html.d.ts +1 -0
  90. package/lib/typescript/module/ui/utils/html.d.ts.map +1 -1
  91. package/lib/typescript/module/ui/utils/post-html.d.ts +54 -0
  92. package/lib/typescript/module/ui/utils/post-html.d.ts.map +1 -0
  93. package/lib/typescript/module/ui/utils/safe-url.d.ts +6 -0
  94. package/lib/typescript/module/ui/utils/safe-url.d.ts.map +1 -1
  95. package/package.json +1 -1
  96. package/src/api/client.ts +32 -0
  97. package/src/i18n.ts +14 -0
  98. package/src/index.tsx +17 -0
  99. package/src/locales.ts +13 -0
  100. package/src/push/push-notifications.ts +74 -8
  101. package/src/state/widget-store.ts +429 -6
  102. package/src/types.ts +77 -0
  103. package/src/ui/WidgetProvider.tsx +78 -2
  104. package/src/ui/components/Post.tsx +943 -0
  105. package/src/ui/utils/html.ts +1 -1
  106. package/src/ui/utils/post-html.ts +256 -0
  107. package/src/ui/utils/safe-url.ts +15 -0
@@ -22,7 +22,7 @@ function decodeNumericEntity(match: string, decimal?: string, hex?: string): str
22
22
  return valid ? String.fromCodePoint(code) : match;
23
23
  }
24
24
 
25
- function decodeEntities(text: string): string {
25
+ export function decodeEntities(text: string): string {
26
26
  return text
27
27
  .replace(/&(amp|lt|gt|quot|#39|apos|nbsp);/g, (match) => ENTITIES[match] ?? match)
28
28
  .replace(/&#(?:(\d{1,7})|[xX]([0-9a-fA-F]{1,6}));/g, decodeNumericEntity);
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Send post HTML → blocks a React Native view can draw.
3
+ *
4
+ * The CRM renders and sanitizes the post before sending it, down to a flat
5
+ * set: `<p>` with `<strong>` / `<em>` / `<code>` / `<a href>` / `<br>`,
6
+ * `<ul>` / `<ol>` of `<li>`, `<pre><code>`, a button
7
+ * (`<div data-post-button data-align>` around one link) and social links
8
+ * (`<div data-post-social data-align>` of `<a data-network>`). React Native
9
+ * has no HTML renderer, so this turns that subset into blocks. Unknown tags
10
+ * keep their text. A link survives when it's one the host could route
11
+ * (`myapp://…` through `onOpenUrl`) or the allow-list would open; tapping it
12
+ * goes through `openExternalUrl` like every other widget link.
13
+ */
14
+
15
+ import { decodeEntities } from './html';
16
+ import { isRoutableUrl } from './safe-url';
17
+
18
+ export interface PostTextRun {
19
+ text: string;
20
+ bold: boolean;
21
+ italic: boolean;
22
+ code: boolean;
23
+ /** Set when the run is a link the SDK may open. */
24
+ href: string | null;
25
+ }
26
+
27
+ export type PostAlign = 'left' | 'center' | 'right';
28
+
29
+ export const POST_SOCIAL_NETWORKS = ['facebook', 'x', 'linkedin', 'instagram', 'youtube'] as const;
30
+ export type PostSocialNetwork = (typeof POST_SOCIAL_NETWORKS)[number];
31
+
32
+ export type PostBlock =
33
+ | { type: 'paragraph'; runs: PostTextRun[] }
34
+ | {
35
+ type: 'list';
36
+ ordered: boolean;
37
+ items: PostTextRun[][];
38
+ /** Per item: how deep it's nested (0 = top level) and its own list's kind. */
39
+ levels: Array<{ depth: number; ordered: boolean }>;
40
+ }
41
+ | { type: 'code'; text: string }
42
+ | { type: 'button'; label: string; href: string; align: PostAlign }
43
+ | { type: 'social'; align: PostAlign; links: Array<{ network: PostSocialNetwork; href: string }> };
44
+
45
+ const TOKEN = /<(\/?)([a-z][a-z0-9]*)([^>]*)>|([^<]+)/gi;
46
+
47
+ function attr(attrs: string, name: string): string | null {
48
+ // Whole names only: `href` must not match inside `data-href`.
49
+ const found = new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'i').exec(attrs);
50
+ return found ? decodeEntities(found[1] ?? found[2] ?? '').trim() : null;
51
+ }
52
+
53
+ const hasAttr = (attrs: string, name: string) => new RegExp(`(^|\\s)${name}(\\s|=|$)`, 'i').test(attrs);
54
+
55
+ function alignOf(attrs: string): PostAlign {
56
+ const value = attr(attrs, 'data-align');
57
+ return value === 'center' || value === 'right' ? value : 'left';
58
+ }
59
+
60
+ function safeHref(attrs: string): string | null {
61
+ const href = attr(attrs, 'href') ?? '';
62
+ return isRoutableUrl(href) ? href : null;
63
+ }
64
+
65
+ /** Merges neighbouring runs that look the same and trims the outer whitespace. */
66
+ function tidy(runs: PostTextRun[]): PostTextRun[] {
67
+ const out: PostTextRun[] = [];
68
+ for (const run of runs) {
69
+ const last = out[out.length - 1];
70
+ if (last && last.bold === run.bold && last.italic === run.italic && last.code === run.code && last.href === run.href) {
71
+ last.text += run.text;
72
+ } else {
73
+ out.push({ ...run });
74
+ }
75
+ }
76
+ if (out.length) {
77
+ out[0].text = out[0].text.replace(/^[ \t]+/, '');
78
+ out[out.length - 1].text = out[out.length - 1].text.replace(/[ \t]+$/, '');
79
+ }
80
+ return out.filter((run) => run.text.length > 0);
81
+ }
82
+
83
+ export function parsePostHtml(html: string): PostBlock[] {
84
+ const blocks: PostBlock[] = [];
85
+ let bold = 0;
86
+ let italic = 0;
87
+ let code = 0;
88
+ const links: Array<string | null> = [];
89
+
90
+ // What's being built right now.
91
+ let runs: PostTextRun[] = [];
92
+ // The top-level list being built, and the lists open inside it (nested lists stay in it, indented).
93
+ let list: { ordered: boolean; items: PostTextRun[][]; levels: Array<{ depth: number; ordered: boolean }> } | null = null;
94
+ const openLists: boolean[] = [];
95
+ let itemDepth = 0;
96
+ let pre: string | null = null;
97
+ let button: { href: string | null; align: PostAlign; label: string } | null = null;
98
+ let social: { align: PostAlign; links: Array<{ network: PostSocialNetwork; href: string }> } | null = null;
99
+
100
+ const flushParagraph = () => {
101
+ const tidied = tidy(runs);
102
+ runs = [];
103
+ if (tidied.length) blocks.push({ type: 'paragraph', runs: tidied });
104
+ };
105
+ const flushItem = () => {
106
+ if (!list) return;
107
+ const tidied = tidy(runs);
108
+ runs = [];
109
+ if (tidied.length) {
110
+ list.items.push(tidied);
111
+ list.levels.push({ depth: openLists.length - 1, ordered: openLists[openLists.length - 1] ?? list.ordered });
112
+ }
113
+ };
114
+ const endList = () => {
115
+ if (list && list.items.length) blocks.push({ type: 'list', ...list });
116
+ list = null;
117
+ openLists.length = 0;
118
+ itemDepth = 0;
119
+ };
120
+
121
+ const pushText = (text: string) => {
122
+ if (!text) return;
123
+ if (pre !== null) {
124
+ pre += text;
125
+ return;
126
+ }
127
+ if (button) {
128
+ button.label += text;
129
+ return;
130
+ }
131
+ if (social) return;
132
+ // Text between list items is only formatting whitespace.
133
+ if (list && itemDepth === 0) return;
134
+ runs.push({
135
+ text: text.replace(/\s+/g, ' '),
136
+ bold: bold > 0,
137
+ italic: italic > 0,
138
+ code: code > 0,
139
+ href: links.length ? links[links.length - 1] : null,
140
+ });
141
+ };
142
+
143
+ for (const match of (html || '').matchAll(TOKEN)) {
144
+ const [, closing, rawTag, attrs = '', text] = match;
145
+ if (text !== undefined) {
146
+ pushText(decodeEntities(text));
147
+ continue;
148
+ }
149
+ const tag = rawTag.toLowerCase();
150
+ const open = !closing;
151
+
152
+ if (pre !== null && tag !== 'pre') continue; // Only text inside a code block.
153
+
154
+ switch (tag) {
155
+ case 'p':
156
+ if (list) {
157
+ // Lists from the editor wrap items in <p>: keep it one line per item.
158
+ if (!open) runs.push({ text: ' ', bold: false, italic: false, code: false, href: null });
159
+ } else {
160
+ flushParagraph();
161
+ }
162
+ break;
163
+ case 'br':
164
+ if (!social && !button) runs.push({ text: '\n', bold: bold > 0, italic: italic > 0, code: code > 0, href: null });
165
+ break;
166
+ case 'strong':
167
+ case 'b':
168
+ bold = Math.max(0, bold + (open ? 1 : -1));
169
+ break;
170
+ case 'em':
171
+ case 'i':
172
+ italic = Math.max(0, italic + (open ? 1 : -1));
173
+ break;
174
+ case 'code':
175
+ code = Math.max(0, code + (open ? 1 : -1));
176
+ break;
177
+ case 'ul':
178
+ case 'ol':
179
+ if (open) {
180
+ if (list) {
181
+ // A list inside an item: what the item said so far is its line.
182
+ flushItem();
183
+ } else {
184
+ flushParagraph();
185
+ list = { ordered: tag === 'ol', items: [], levels: [] };
186
+ }
187
+ openLists.push(tag === 'ol');
188
+ } else if (list) {
189
+ flushItem();
190
+ openLists.pop();
191
+ if (openLists.length === 0) endList();
192
+ }
193
+ break;
194
+ case 'li':
195
+ flushItem();
196
+ if (list) itemDepth = Math.max(0, itemDepth + (open ? 1 : -1));
197
+ break;
198
+ case 'pre':
199
+ if (open) {
200
+ flushParagraph();
201
+ pre = '';
202
+ } else if (pre !== null) {
203
+ const textBlock = pre.replace(/^\n+|\n+$/g, '');
204
+ if (textBlock) blocks.push({ type: 'code', text: textBlock });
205
+ pre = null;
206
+ }
207
+ break;
208
+ case 'div':
209
+ if (open && hasAttr(attrs, 'data-post-button')) {
210
+ flushParagraph();
211
+ button = { href: null, align: alignOf(attrs), label: '' };
212
+ } else if (open && hasAttr(attrs, 'data-post-social')) {
213
+ flushParagraph();
214
+ social = { align: alignOf(attrs), links: [] };
215
+ } else if (!open) {
216
+ if (button) {
217
+ const label = button.label.replace(/\s+/g, ' ').trim();
218
+ if (label && button.href) blocks.push({ type: 'button', label, href: button.href, align: button.align });
219
+ button = null;
220
+ } else if (social) {
221
+ if (social.links.length) blocks.push({ type: 'social', align: social.align, links: social.links });
222
+ social = null;
223
+ } else {
224
+ flushParagraph();
225
+ }
226
+ }
227
+ break;
228
+ case 'a':
229
+ if (button) {
230
+ if (open) button.href = safeHref(attrs);
231
+ } else if (social) {
232
+ if (open) {
233
+ const network = attr(attrs, 'data-network') as PostSocialNetwork | null;
234
+ const href = safeHref(attrs);
235
+ if (network && (POST_SOCIAL_NETWORKS as readonly string[]).includes(network) && href) {
236
+ social.links.push({ network, href });
237
+ }
238
+ }
239
+ } else if (open) {
240
+ links.push(safeHref(attrs));
241
+ } else {
242
+ links.pop();
243
+ }
244
+ break;
245
+ default:
246
+ break;
247
+ }
248
+ }
249
+ if (list) {
250
+ flushItem();
251
+ endList();
252
+ }
253
+ flushParagraph();
254
+ return blocks;
255
+ }
256
+
@@ -42,6 +42,21 @@ export function isSafeExternalUrl(url: string): boolean {
42
42
  return scheme === 'http:' || scheme === 'https:' ? isHttpUrl(url) : true;
43
43
  }
44
44
 
45
+ /** Schemes no host would route — content carrying them never shows as a link. */
46
+ const NEVER_ROUTED_SCHEMES = new Set(['javascript:', 'vbscript:', 'data:', 'file:', 'blob:']);
47
+
48
+ /**
49
+ * Worth drawing as a link: absolute, with a scheme the host could route
50
+ * through `onOpenUrl` (`myapp://…`) or the allow-list would open. Tapping it
51
+ * still goes through `openExternalUrl`, which makes the actual call.
52
+ */
53
+ export function isRoutableUrl(url: string): boolean {
54
+ if (!url || CONTROL_CHARS.test(url)) return false;
55
+ const scheme = urlScheme(url);
56
+ if (!scheme || NEVER_ROUTED_SCHEMES.has(scheme)) return false;
57
+ return scheme === 'http:' || scheme === 'https:' ? isHttpUrl(url) : true;
58
+ }
59
+
45
60
  /** Host handler first, then the allow-list. Never throws. */
46
61
  export async function openExternalUrl(url: string | null | undefined): Promise<void> {
47
62
  const target = (url ?? '').trim();