@joryio/web-sdk 0.0.0-stage → 1.3.0-beta.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 (76) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +54 -0
  3. package/README.md +497 -2
  4. package/dist/core/config.d.ts +101 -0
  5. package/dist/core/config.d.ts.map +1 -0
  6. package/dist/core/device.d.ts +14 -0
  7. package/dist/core/device.d.ts.map +1 -0
  8. package/dist/core/ecommerce.d.ts +67 -0
  9. package/dist/core/ecommerce.d.ts.map +1 -0
  10. package/dist/core/identity.d.ts +36 -0
  11. package/dist/core/identity.d.ts.map +1 -0
  12. package/dist/core/inapp.d.ts +169 -0
  13. package/dist/core/inapp.d.ts.map +1 -0
  14. package/dist/core/network.d.ts +158 -0
  15. package/dist/core/network.d.ts.map +1 -0
  16. package/dist/core/outbox.d.ts +79 -0
  17. package/dist/core/outbox.d.ts.map +1 -0
  18. package/dist/core/pending-attributes.d.ts +43 -0
  19. package/dist/core/pending-attributes.d.ts.map +1 -0
  20. package/dist/core/push.d.ts +59 -0
  21. package/dist/core/push.d.ts.map +1 -0
  22. package/dist/core/queue.d.ts +107 -0
  23. package/dist/core/queue.d.ts.map +1 -0
  24. package/dist/core/sdk.d.ts +169 -0
  25. package/dist/core/sdk.d.ts.map +1 -0
  26. package/dist/core/session.d.ts +36 -0
  27. package/dist/core/session.d.ts.map +1 -0
  28. package/dist/core/storage.d.ts +17 -0
  29. package/dist/core/storage.d.ts.map +1 -0
  30. package/dist/core/user.d.ts +28 -0
  31. package/dist/core/user.d.ts.map +1 -0
  32. package/dist/core/utm.d.ts +39 -0
  33. package/dist/core/utm.d.ts.map +1 -0
  34. package/dist/core/values.d.ts +3 -0
  35. package/dist/core/values.d.ts.map +1 -0
  36. package/dist/core/write-retry.d.ts +66 -0
  37. package/dist/core/write-retry.d.ts.map +1 -0
  38. package/dist/index.d.mts +711 -0
  39. package/dist/index.d.ts +21 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/joryio-sw.js +6 -0
  42. package/dist/joryio.cjs.js +7265 -0
  43. package/dist/joryio.min.js +7 -0
  44. package/dist/joryio.min.js.map +1 -0
  45. package/dist/joryio.mjs +7254 -0
  46. package/dist/joryio.umd.js +7271 -0
  47. package/dist/utils/bucket-hash.d.ts +2 -0
  48. package/dist/utils/bucket-hash.d.ts.map +1 -0
  49. package/dist/utils/logger.d.ts +14 -0
  50. package/dist/utils/logger.d.ts.map +1 -0
  51. package/dist/utils/uuid.d.ts +3 -0
  52. package/dist/utils/uuid.d.ts.map +1 -0
  53. package/package.json +90 -4
  54. package/src/core/config.ts +362 -0
  55. package/src/core/device.ts +50 -0
  56. package/src/core/ecommerce.ts +482 -0
  57. package/src/core/identity.ts +251 -0
  58. package/src/core/inapp.ts +2419 -0
  59. package/src/core/network.ts +1261 -0
  60. package/src/core/outbox.ts +707 -0
  61. package/src/core/pending-attributes.ts +350 -0
  62. package/src/core/push.ts +667 -0
  63. package/src/core/queue.ts +894 -0
  64. package/src/core/sdk.ts +3108 -0
  65. package/src/core/session.ts +311 -0
  66. package/src/core/storage.ts +157 -0
  67. package/src/core/user.ts +203 -0
  68. package/src/core/utm.ts +304 -0
  69. package/src/core/values.ts +85 -0
  70. package/src/core/write-retry.ts +136 -0
  71. package/src/index.ts +305 -0
  72. package/src/snippet/loader.js +134 -0
  73. package/src/utils/bucket-hash.ts +44 -0
  74. package/src/utils/logger.ts +58 -0
  75. package/src/utils/uuid.ts +29 -0
  76. package/src/worker/joryio-sw.js +218 -0
@@ -0,0 +1,2419 @@
1
+ /**
2
+ * In-App Messaging Manager
3
+ * Handles in-app message campaigns, display, and tracking
4
+ */
5
+
6
+ import { Logger } from '../utils/logger';
7
+ import type { InAppDisplayOptions } from './config';
8
+ import { IdentityManager } from './identity';
9
+ import { SessionManager } from './session';
10
+ import { StorageManager } from './storage';
11
+ import { SdkAuthFailureInfo } from './network';
12
+
13
+ /**
14
+ * The slice of NetworkClient the in-app manager needs to attach the
15
+ * `X-Joryio-Auth` header to its own fetches and to trigger a token refresh
16
+ * on a 401 `sdk_authentication_error`. Kept as a minimal interface so tests
17
+ * can stub it without constructing a full NetworkClient.
18
+ */
19
+ export interface InAppAuthProvider {
20
+ /** `subjectUserId`: whose request it is - a logged-out user's carries that user's own token. */
21
+ getSdkAuthHeaders(subjectUserId?: string | null): Record<string, string>;
22
+ isSdkAuthError(status: number, data: any): boolean;
23
+ refreshSdkAuthToken(info: SdkAuthFailureInfo): Promise<string | undefined>;
24
+ /** The signed-in user; the host's auth handler is only asked about THEIR requests (contract item 39). */
25
+ getCurrentUserId?(): string | null;
26
+ }
27
+
28
+ /**
29
+ * How the SDK runs the in-app manager. Everything optional so the manager can
30
+ * be constructed bare in tests.
31
+ */
32
+ export interface InAppRuntimeOptions {
33
+ /**
34
+ * False while tracking is opted out, consent is pending or ingestion is
35
+ * suspended: no sync, no impressions - nothing leaves the device.
36
+ */
37
+ canSend?: () => boolean;
38
+ /** Per-request timeout (the `requestTimeout` init option). Default 10 s. */
39
+ requestTimeoutMs?: number;
40
+ /** Periodic re-sync in ms; 0 = off. Clamped by config to >= 30 s. */
41
+ syncIntervalMs?: number;
42
+ /** `session-start-only` disables the attribute-change and periodic re-syncs. */
43
+ evaluationMode?: 'differential-sync' | 'session-start-only';
44
+ /** Report an HTTP status from the manager's own fetches (402 suspends the SDK). */
45
+ noteStatus?: (status: number, data: any) => void;
46
+ }
47
+
48
+ export interface InAppCampaign {
49
+ id: string;
50
+ name: string;
51
+ type: 'modal' | 'banner' | 'slideup' | 'fullscreen' | 'custom';
52
+ priority: number;
53
+ evaluationMode: 'differential-sync' | 'session-start-only';
54
+ frequencyCap?: {
55
+ /** null / absent = UNLIMITED. checkFrequencyCap already reads it that way. */
56
+ maxImpressions?: number | null;
57
+ timeWindow: string;
58
+ minDelayBetweenImpressions?: number;
59
+ };
60
+ variantId?: string;
61
+ variantName?: string;
62
+ /**
63
+ * Opaque handle present when this message was queued by a JOURNEY node rather
64
+ * than a standalone campaign. It MUST be echoed back on every track call: the
65
+ * server resolves it to the canvas/node/execution that queued the message, and
66
+ * that is the ONLY way journey in-app attribution (per-node stats, conversions)
67
+ * is recorded. A client-supplied canvasId is never trusted for this.
68
+ */
69
+ pendingId?: string;
70
+ /**
71
+ * Signed proof that the SERVER served this exact message, to this subject, for
72
+ * this campaign/variant. Issued per delivery in the sync response.
73
+ *
74
+ * MUST be echoed back verbatim on every track call. The server takes tenant,
75
+ * campaign, variant and subject FROM THE TOKEN and ignores the equivalent body
76
+ * fields, and permits each transition (displayed/clicked/dismissed/converted)
77
+ * exactly once per delivery. That is what stops a holder of the publishable SDK
78
+ * key reporting interactions with content that was never served, or replaying
79
+ * one interaction to inflate campaign and A/B metrics.
80
+ *
81
+ * Treat it as opaque — never construct, parse, or reuse one across messages.
82
+ * Absent for an anonymous visitor with no resolvable subject, and for servers
83
+ * predating the token; the backend accepts tokenless calls until
84
+ * IN_APP_REQUIRE_DELIVERY_TOKEN is switched on.
85
+ */
86
+ deliveryToken?: string;
87
+ /** 'journey' when queued by a canvas node; absent for campaign in-apps. */
88
+ source?: string;
89
+ /**
90
+ * What to render. ONE field, discriminated on `kind`.
91
+ *
92
+ * Replaced `template` + `renderedHtml` + `liquidEnabled`. This SDK read
93
+ * `template.html` (which the server sent RAW) and skipped its Liquid pass
94
+ * because the server set `liquidEnabled:false` to mean "already rendered
95
+ * elsewhere" - so personalised messages displayed `{{ user.firstName }}`
96
+ * verbatim on web. Server-rendered HTML is now the only thing sent.
97
+ */
98
+ content?:
99
+ | { kind: 'html'; html: string; css: string; assets?: unknown[] }
100
+ | {
101
+ kind: 'native';
102
+ title?: string;
103
+ body: string;
104
+ imageUrl?: string;
105
+ buttons?: Array<{ id: string; text: string; action: string; url?: string }>;
106
+ closeButton?: boolean;
107
+ backdropDismissible?: boolean;
108
+ };
109
+ targeting?: {
110
+ filterGroups: any[];
111
+ };
112
+ triggers: InAppTrigger[];
113
+ /**
114
+ * Synced as triggers WITHOUT content: ask the server at display time.
115
+ *
116
+ * Set per campaign in the editor. Costs the offline capability for this
117
+ * campaign - with no content held, a trigger firing without a network shows
118
+ * nothing, which is correct: the whole point is not to display something we
119
+ * cannot re-verify.
120
+ */
121
+ reevaluateBeforeDisplay?: boolean;
122
+ watchedAttributes?: string[];
123
+ }
124
+
125
+ export interface InAppTrigger {
126
+ /**
127
+ * Matches the SERVER's union (campaign.entity.ts). `time_delay` used to be
128
+ * listed here and is NOT a type the backend or the campaign editor can
129
+ * produce - a phantom that would have had a handler written for it.
130
+ * `push_notification_tap` is the reverse: real, offered in the editor, and
131
+ * declared by no SDK until now.
132
+ */
133
+ type:
134
+ | 'immediate'
135
+ | 'event'
136
+ | 'attribute_change'
137
+ | 'attribute_threshold'
138
+ | 'push_notification_tap';
139
+ event?: string;
140
+ attribute?: string;
141
+ operator?: string;
142
+ value?: any;
143
+ delay?: number;
144
+ }
145
+
146
+ export interface InAppMessage {
147
+ campaign: InAppCampaign;
148
+ renderedHtml: string;
149
+ displayedAt?: Date;
150
+ }
151
+
152
+ export interface InAppSyncResponse {
153
+ success: boolean;
154
+ campaigns: InAppCampaign[];
155
+ evaluatedAt: string;
156
+ nextEvaluationStrategy?: string;
157
+ }
158
+
159
+ /**
160
+ * What the renderer accepts.
161
+ *
162
+ * EXTENDS the config type rather than re-listing overlapping fields. These were
163
+ * two independent interfaces describing the same thing, and the renderer's copy
164
+ * silently lacked zIndex / backdropColor / closeButtonColor / defaultPosition /
165
+ * defaultAnimation - so those config options resolved with defaults, were
166
+ * dropped on the way in, and could never have had an effect. Extending means a
167
+ * field added to the config surface cannot go missing here again.
168
+ */
169
+ export interface DisplayOptions extends InAppDisplayOptions {
170
+ container?: HTMLElement;
171
+ closeButton?: boolean;
172
+ backdropClose?: boolean;
173
+ animationDuration?: number;
174
+ onDisplay?: (message: InAppMessage) => void;
175
+ onClick?: (message: InAppMessage, action: string) => void;
176
+ onDismiss?: (message: InAppMessage, action: string) => void;
177
+ onConversion?: (message: InAppMessage, event: string) => void;
178
+ }
179
+
180
+ /**
181
+ * SDK methods the in-app `joryioBridge` proxies to. Passed in from the SDK so
182
+ * this manager stays decoupled from JoryioSDK. `requestPushPermission` MUST
183
+ * dispatch synchronously (it's called inside the message's click handler — the
184
+ * same-origin iframe preserves the user gesture only if the chain stays sync).
185
+ */
186
+ export interface InAppBridge {
187
+ logCustomEvent: (name: string, properties?: Record<string, any>) => void;
188
+ setCustomUserAttribute: (key: string, value: any) => void;
189
+ changeUser: (userId: string) => void;
190
+ requestPushPermission: () => void;
191
+ /** Pre-warm web push (VAPID + SW) when a primer is shown, so the gesture-bound
192
+ * requestPushPermission() prompts reliably. No-op/never prompts. */
193
+ preparePushPermission?: () => void;
194
+ }
195
+
196
+ /**
197
+ * CSS placed inside a <style> element of an HTML document: `</` becomes
198
+ * `<\/`, so no `</style>` in it can close the element (rule 57). A backslash
199
+ * before `/` is an escape the CSS parser reads as `/`.
200
+ */
201
+ export function escapeStyleText(css: string): string {
202
+ return typeof css === 'string' ? css.replace(/<\//g, '<\\/') : '';
203
+ }
204
+
205
+ // Host-window key the in-app iframes call into (same-origin). Internal.
206
+ const HOST_BRIDGE_KEY = '__joryioInApp';
207
+
208
+ // Storage key for persisted per-campaign impression timestamps. Persisting these
209
+ // is what makes 'once'/'lifetime'/'session' frequency caps survive page reloads
210
+ // (in-memory only would re-fire the message on every load).
211
+ const IMPRESSIONS_STORAGE_KEY = 'inapp_impressions';
212
+
213
+ // Size limits to prevent unbounded memory growth
214
+ const MAX_DISPLAYED_MESSAGES = 500;
215
+ const MAX_IMPRESSION_TIMESTAMPS = 500;
216
+ const MAX_WATCHED_ATTRIBUTES = 200;
217
+
218
+ /** The shape of a parsed CSS rule this module cares about - a structural
219
+ * subset of CSSOM, so the rewriting below is testable without a browser. */
220
+ interface ScopableRule {
221
+ selectorText?: string;
222
+ style?: { cssText: string };
223
+ conditionText?: string;
224
+ cssRules?: ArrayLike<unknown>;
225
+ cssText?: string;
226
+ type?: number;
227
+ }
228
+
229
+ /**
230
+ * Rewrites parsed CSS rules so each one applies only inside `scope`.
231
+ *
232
+ * Split out from the DOM plumbing on purpose: this is the part with the
233
+ * security consequences, so it is a pure function that can be tested directly.
234
+ */
235
+ export function scopeCssRules(rules: ArrayLike<unknown>, scope: string): string {
236
+ const scopeSelector = (selectorText: string): string =>
237
+ selectorText
238
+ .split(',')
239
+ .map((part) => {
240
+ const one = part.trim();
241
+ if (!one) return '';
242
+ // An author writing `body` or `:root` means "this message" - that is
243
+ // the only element they can see. Anything else nests under the scope.
244
+ if (/^(?:html|body|:root)$/i.test(one)) return scope;
245
+ return `${scope} ${one}`;
246
+ })
247
+ .filter(Boolean)
248
+ .join(', ');
249
+
250
+ const out: string[] = [];
251
+ for (let i = 0; i < rules.length; i++) {
252
+ const rule = rules[i] as ScopableRule;
253
+ if (rule.selectorText !== undefined && rule.style) {
254
+ const selector = scopeSelector(rule.selectorText);
255
+ if (selector) out.push(`${selector} { ${rule.style.cssText} }`);
256
+ } else if (rule.cssRules && rule.conditionText !== undefined) {
257
+ // @media / @supports: keep the condition, scope what is inside it.
258
+ const header = (rule.cssText ?? '').slice(0, (rule.cssText ?? '').indexOf('{')).trim();
259
+ const inner = scopeCssRules(rule.cssRules, scope);
260
+ if (header && inner) out.push(`${header} { ${inner} }`);
261
+ } else if (rule.type === 7 && rule.cssText) {
262
+ // Keyframes are referenced by name, not matched by selector, so there is
263
+ // nothing to scope - and dropping them would break any animation the
264
+ // author's scoped rules refer to.
265
+ out.push(rule.cssText);
266
+ }
267
+ // Everything else - notably @import, which would pull an unscopable remote
268
+ // stylesheet onto the customer's page - is dropped on purpose.
269
+ }
270
+ return out.join('\n');
271
+ }
272
+
273
+ /**
274
+ * Black or white, whichever stays legible on `background`.
275
+ *
276
+ * Rec. 709 luma, the weighting the WCAG contrast formula uses. Returns
277
+ * undefined for anything it cannot measure, so the caller keeps the system
278
+ * default rather than guessing.
279
+ */
280
+ function readableForeground(background: unknown): string | undefined {
281
+ if (typeof background !== 'string') return undefined;
282
+ const hex = background.trim().replace(/^#/, '');
283
+ const full = hex.length === 3 ? hex.replace(/(.)/g, '$1$1') : hex;
284
+ if (!/^[0-9a-f]{6}$/i.test(full)) return undefined; // named colour, rgb(), gradient - leave it
285
+ const [r, g, b] = [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16));
286
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b > 150 ? '#0A1240' : '#FFFFFF';
287
+ }
288
+
289
+ /**
290
+ * A fresh nonce per rendered message.
291
+ *
292
+ * Must be unpredictable and must never repeat across documents: a nonce an
293
+ * attacker can guess, or one reused from an earlier message, is a nonce they
294
+ * can put on their own injected <script> - which is the entire thing this
295
+ * defends against. crypto.getRandomValues where available; the Math.random
296
+ * fallback is for engines so old the CSP would go unenforced anyway.
297
+ */
298
+ function randomNonce(): string {
299
+ const bytes = new Uint8Array(16);
300
+ const c = (globalThis as { crypto?: Crypto }).crypto;
301
+ if (c && typeof c.getRandomValues === 'function') {
302
+ c.getRandomValues(bytes);
303
+ } else {
304
+ for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
305
+ }
306
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
307
+ }
308
+
309
+ /**
310
+ * Which button or link a click was on, sent as `elementId` - the same field
311
+ * name on every SDK (backend /v1/in-app/track, max 128). A generic name
312
+ * ("click", "button", "cta_click") says nothing about WHICH element, so it is
313
+ * not sent; the same list as iOS and Android.
314
+ */
315
+ const GENERIC_ELEMENT_NAMES = new Set(['click', 'clicked', 'button', 'link', 'cta_click', 'link_click', 'button_click']);
316
+ export function normalizedElementId(raw: unknown): string | undefined {
317
+ if (typeof raw !== 'string') return undefined;
318
+ const trimmed = raw.trim();
319
+ if (!trimmed || GENERIC_ELEMENT_NAMES.has(trimmed.toLowerCase())) return undefined;
320
+ return trimmed.slice(0, 128);
321
+ }
322
+
323
+ /** Message types that carry their own placement; `displayOptions.defaultPosition` places the rest. */
324
+ const BUILT_IN_POSITIONS = new Set(['modal', 'banner', 'slideup', 'fullscreen']);
325
+
326
+ /**
327
+ * A colour (or other single CSS value) from `displayOptions`, safe to write
328
+ * into a stylesheet: anything that could close the declaration or the rule is
329
+ * refused rather than escaped.
330
+ */
331
+ export function cssValue(raw: unknown): string | null {
332
+ if (typeof raw !== 'string') return null;
333
+ const value = raw.trim();
334
+ if (!value || value.length > 100 || /[;{}<>\\]/.test(value)) return null;
335
+ return value;
336
+ }
337
+
338
+ export class InAppMessagingManager {
339
+ /** Messages waiting out a display delay, so they can be cancelled. */
340
+ private pendingDelays = new Map<string, ReturnType<typeof setTimeout>>();
341
+
342
+ private logger: Logger;
343
+ private identity: IdentityManager;
344
+ private session: SessionManager;
345
+ private storage: StorageManager | null;
346
+ private apiEndpoint: string;
347
+ private sdkKey: string;
348
+
349
+ private campaigns: Map<string, InAppCampaign> = new Map();
350
+ private displayedMessages: Map<string, InAppMessage> = new Map();
351
+ private impressionTimestamps: Map<string, number[]> = new Map();
352
+ private watchedAttributes: Set<string> = new Set();
353
+ /** The 5 s local attribute-diff poll. */
354
+ private syncInterval?: number;
355
+ /** The optional periodic server re-sync (`inApp.syncInterval`). */
356
+ private periodicSyncTimer?: ReturnType<typeof setInterval>;
357
+ /** The one-shot retry after a failed sync. */
358
+ private syncRetryTimer?: ReturnType<typeof setTimeout>;
359
+ private runtime: Required<Omit<InAppRuntimeOptions, 'noteStatus'>> & Pick<InAppRuntimeOptions, 'noteStatus'>;
360
+ private displayOptions: DisplayOptions = {};
361
+ private isSyncing = false;
362
+ private syncRetried = false;
363
+ /**
364
+ * Bumped whenever WHO the campaigns are for changes (reset, wipeData,
365
+ * identify, a consent or opt-out change). A sync or resolve that started
366
+ * under an older generation is discarded when it answers: reset() during an
367
+ * in-flight sync used to install the previous user's campaigns for the next
368
+ * one (contract item 26).
369
+ */
370
+ private generation = 0;
371
+ /** A sync was asked for while one was in flight: run one more right after it. */
372
+ private syncAgain = false;
373
+ private syncAgainChanges: Record<string, any> | undefined;
374
+ /**
375
+ * Campaigns claimed for display while the async part of displayCampaign
376
+ * runs (re-check, render). Claimed SYNCHRONOUSLY, so two triggers in one
377
+ * burst cannot both pass the "already shown?" check (contract item 27).
378
+ */
379
+ private claimed = new Set<string>();
380
+ // Per-campaign content iframe (so we can auto-size + clean it up).
381
+ private iframes: Map<string, HTMLIFrameElement> = new Map();
382
+
383
+ /**
384
+ * Whether HTML in-app messages may run. Off by default, because an HTML
385
+ * message executes author-supplied JavaScript on the host page - which is
386
+ * exactly why it is rendered in a sandboxed iframe. Native messages are
387
+ * unaffected: they are data rendered as DOM, with no interpreter.
388
+ */
389
+ private allowHtmlJsInAppMessages = false;
390
+
391
+ /**
392
+ * What this build can actually render, sent on every session sync.
393
+ *
394
+ * Derived from the live config rather than hardcoded, so it cannot drift from
395
+ * what `undisplayableReason` will decide moments later - the whole value of
396
+ * this field is that the server's picture matches the device's behaviour.
397
+ *
398
+ * `content.native` is unconditional: native messages are DOM this SDK draws
399
+ * itself, with no interpreter and nothing to opt into. `content.html` appears
400
+ * only when the integration opted in, because for everyone else HTML
401
+ * genuinely cannot display - which is precisely what the campaign editor
402
+ * needs to be told before someone publishes into silence.
403
+ */
404
+ private capabilities(): string[] {
405
+ const caps = ['content.native'];
406
+ if (this.allowHtmlJsInAppMessages) caps.push('content.html');
407
+ return caps;
408
+ }
409
+
410
+ /**
411
+ * Run a host-app callback. A callback that throws must never stop the SDK's
412
+ * own bookkeeping (the dismissal, the impression, the cleanup) - contract
413
+ * item 34.
414
+ */
415
+ private callHost(name: string, fn: () => void): void {
416
+ try {
417
+ fn();
418
+ } catch (error) {
419
+ this.logger.error(`The ${name} callback threw; ignored:`, error);
420
+ }
421
+ }
422
+
423
+ /**
424
+ * The audience changed (reset, wipeData, identify, consent): answers to
425
+ * requests made before now are stale. A sync in flight is followed by a
426
+ * fresh one.
427
+ */
428
+ invalidate(): void {
429
+ this.generation++;
430
+ if (this.isSyncing) this.syncAgain = true;
431
+ }
432
+
433
+ /** Set from SDK init (allowHtmlJsInAppMessages). */
434
+ setAllowHtmlJsInAppMessages(value: boolean): void {
435
+ this.allowHtmlJsInAppMessages = value;
436
+ }
437
+ private bridge: InAppBridge | null = null;
438
+ private auth: InAppAuthProvider | null = null;
439
+
440
+ constructor(
441
+ apiEndpoint: string,
442
+ sdkKey: string,
443
+ identity: IdentityManager,
444
+ session: SessionManager,
445
+ logger: Logger,
446
+ storage?: StorageManager | null,
447
+ options?: DisplayOptions,
448
+ bridge?: InAppBridge,
449
+ auth?: InAppAuthProvider | null,
450
+ runtime?: InAppRuntimeOptions,
451
+ ) {
452
+ this.runtime = {
453
+ canSend: runtime?.canSend || (() => true),
454
+ requestTimeoutMs: runtime?.requestTimeoutMs || 10_000,
455
+ syncIntervalMs: runtime?.syncIntervalMs || 0,
456
+ evaluationMode: runtime?.evaluationMode || 'differential-sync',
457
+ noteStatus: runtime?.noteStatus,
458
+ };
459
+ this.apiEndpoint = apiEndpoint;
460
+ this.sdkKey = sdkKey;
461
+ this.identity = identity;
462
+ this.session = session;
463
+ this.logger = logger;
464
+ this.storage = storage || null;
465
+ this.displayOptions = options || {};
466
+ this.bridge = bridge || null;
467
+ this.auth = auth || null;
468
+ this.loadImpressionTimestamps();
469
+ this.installHostBridge();
470
+ }
471
+
472
+ /**
473
+ * Load persisted per-campaign impression timestamps into memory so 'once'/
474
+ * 'lifetime'/'session' frequency caps hold across page reloads and sessions.
475
+ */
476
+ private loadImpressionTimestamps(): void {
477
+ if (!this.storage) return;
478
+ try {
479
+ const stored = this.storage.get<Record<string, number[]>>(IMPRESSIONS_STORAGE_KEY);
480
+ if (stored && typeof stored === 'object') {
481
+ for (const [campaignId, timestamps] of Object.entries(stored)) {
482
+ if (Array.isArray(timestamps)) {
483
+ this.impressionTimestamps.set(campaignId, timestamps.filter((t) => typeof t === 'number'));
484
+ }
485
+ }
486
+ }
487
+ } catch (error) {
488
+ this.logger.error('Failed to load persisted impression timestamps:', error);
489
+ }
490
+ }
491
+
492
+ /**
493
+ * Persist the current per-campaign impression timestamps to storage.
494
+ */
495
+ private persistImpressionTimestamps(): void {
496
+ if (!this.storage) return;
497
+ try {
498
+ const serialized: Record<string, number[]> = {};
499
+ this.impressionTimestamps.forEach((timestamps, campaignId) => {
500
+ serialized[campaignId] = timestamps;
501
+ });
502
+ this.storage.set(IMPRESSIONS_STORAGE_KEY, serialized);
503
+ } catch (error) {
504
+ this.logger.error('Failed to persist impression timestamps:', error);
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Expose the host-side bridge object the message iframes call into. Each
510
+ * method is scoped by campaignId (validated against displayed messages) so a
511
+ * message can only act on itself. Same-origin, synchronous — which is what
512
+ * lets requestPushPermission keep the user-gesture activation. Installed once
513
+ * (idempotent); the in-app HTML reaches it via `window.parent[HOST_BRIDGE_KEY]`.
514
+ */
515
+ private installHostBridge(): void {
516
+ if (typeof window === 'undefined') return;
517
+ // Overwrite unconditionally. The SDK is a singleton, so there's one manager;
518
+ // overwriting (vs skip-if-exists) means a destroy()+re-init rebinds to the
519
+ // LIVE instance instead of leaving a stale bridge that no-ops every call.
520
+ // NOTE: every method GUARDS on displayedMessages.has(campaignId) — a message
521
+ // can only act while it's on screen. The id scopes ANALYTICS attribution only;
522
+ // it unlocks no per-campaign capability (all methods proxy the one SDK
523
+ // singleton), so a cross-campaign id at most mis-attributes a click. Keep this
524
+ // guard on any NEW method too.
525
+ (window as any)[HOST_BRIDGE_KEY] = {
526
+ logCustomEvent: (campaignId: string, name: string, properties?: Record<string, any>) => {
527
+ if (!this.displayedMessages.has(campaignId) || !name) return;
528
+ this.bridge?.logCustomEvent(name, properties);
529
+ },
530
+ setCustomUserAttribute: (campaignId: string, key: string, value: any) => {
531
+ if (!this.displayedMessages.has(campaignId) || !key) return;
532
+ this.bridge?.setCustomUserAttribute(key, value);
533
+ },
534
+ changeUser: (campaignId: string, userId: string) => {
535
+ if (!this.displayedMessages.has(campaignId) || !userId) return;
536
+ this.bridge?.changeUser(userId);
537
+ },
538
+ requestPushPermission: (campaignId: string) => {
539
+ if (!this.displayedMessages.has(campaignId)) return;
540
+ // Synchronous — preserves the click's user activation for the OS prompt.
541
+ this.bridge?.requestPushPermission();
542
+ },
543
+ logConversion: (campaignId: string, event: string) => {
544
+ if (!this.displayedMessages.has(campaignId) || !event) return;
545
+ this.trackConversion(campaignId, event).catch(() => {});
546
+ },
547
+ logClick: (campaignId: string, action?: string) => {
548
+ if (!this.displayedMessages.has(campaignId)) return;
549
+ const elementId = normalizedElementId(action);
550
+ this.trackImpression(campaignId, 'clicked', elementId ? { elementId } : undefined).catch(() => {});
551
+ const message = this.displayedMessages.get(campaignId);
552
+ if (message) this.callHost('onMessageClick', () => this.displayOptions.onClick?.(message, action || 'click'));
553
+ },
554
+ // Navigate a CTA link. The sandboxed iframe can't navigate the top page
555
+ // itself, so the parent does it (within the click's user gesture). Only
556
+ // http(s)/relative URLs — never javascript:/data: etc.
557
+ navigate: (campaignId: string, url: string, target?: string) => {
558
+ if (!this.displayedMessages.has(campaignId) || typeof url !== 'string') return;
559
+ // Absolute http(s) or a single-leading-slash root path (resolved against
560
+ // the TOP origin). Rejects javascript:/data:/mailto:/tel: and protocol-
561
+ // relative `//host` (open-redirect) — those aren't routed here.
562
+ if (!/^(https?:\/\/|\/(?!\/))/i.test(url)) return;
563
+ if (target === '_top' || target === '_self') {
564
+ if (window.top) window.top.location.href = url;
565
+ } else {
566
+ window.open(url, '_blank', 'noopener');
567
+ }
568
+ },
569
+ closeMessage: (campaignId: string) => {
570
+ if (!this.displayedMessages.has(campaignId)) return;
571
+ this.dismiss(campaignId, 'bridge_close').catch(() => {});
572
+ },
573
+ // A form inside the message (landing pages phase C): the values go to the
574
+ // backend with THIS SDK's identity, so an anonymous visitor's submission
575
+ // lands on their profile once the form names them. Returns the server's
576
+ // answer ({ ok, message | redirect | errors }) to the message's runtime.
577
+ submitForm: (campaignId: string, values: Record<string, unknown>) => {
578
+ if (!this.displayedMessages.has(campaignId) || !values || typeof values !== 'object') return Promise.resolve({ ok: false, message: 'This message is no longer open.' });
579
+ this.trackImpression(campaignId, 'clicked').catch(() => {});
580
+ return this.submitForm(campaignId, values);
581
+ },
582
+ // Size the content iframe from the message's reported dimensions. For
583
+ // content-sized types (modal/slideup) both width + height track the content;
584
+ // banner/fullscreen stay full-width (height tracks content for banner).
585
+ setSize: (campaignId: string, width: number, height: number) => {
586
+ // Guard on the iframe (the thing being sized), set synchronously in
587
+ // display() — not displayedMessages, so a setSize on iframe-load is never
588
+ // rejected by a timing gap.
589
+ const iframe = this.iframes.get(campaignId);
590
+ if (!iframe || !Number.isFinite(height) || height <= 0) return;
591
+ const type = this.displayedMessages.get(campaignId)?.campaign.type;
592
+ iframe.style.height = type === 'fullscreen' ? '100%' : `${Math.ceil(height)}px`;
593
+ if ((type === 'modal' || type === 'slideup') && Number.isFinite(width) && width > 0) {
594
+ iframe.style.width = `${Math.ceil(width)}px`;
595
+ }
596
+ },
597
+ };
598
+ }
599
+
600
+
601
+ /**
602
+ * Initialize in-app messaging
603
+ */
604
+ async initialize(): Promise<void> {
605
+ if (!this.runtime.canSend()) return;
606
+ this.logger.info('Initializing in-app messaging');
607
+
608
+ // Idempotent: a second initialize() (consent granted again, identify,
609
+ // reset) used to start a SECOND attribute-watch interval on top of the
610
+ // first. Stop whatever is running before starting it again.
611
+ this.stop();
612
+
613
+ // Sync eligible campaigns. A failure is retried once by sync() itself and
614
+ // must not stop the watchers from starting.
615
+ await this.sync().catch((error) => {
616
+ this.logger.error('Initial in-app sync failed:', error);
617
+ });
618
+
619
+ // Start watching for attribute changes (if differential-sync campaigns exist)
620
+ this.startAttributeWatching();
621
+ this.startPeriodicSync();
622
+ }
623
+
624
+ /**
625
+ * Stop every timer this manager owns - the attribute poll, the periodic
626
+ * re-sync, the failed-sync retry, and display delays not yet elapsed - while
627
+ * keeping the campaigns already synced. Used when tracking is switched off
628
+ * (opt-out, consent withdrawn) and by clearAll()/destroy().
629
+ */
630
+ stop(): void {
631
+ if (this.syncInterval) {
632
+ clearInterval(this.syncInterval);
633
+ this.syncInterval = undefined;
634
+ }
635
+ if (this.periodicSyncTimer) {
636
+ clearInterval(this.periodicSyncTimer);
637
+ this.periodicSyncTimer = undefined;
638
+ }
639
+ if (this.syncRetryTimer) {
640
+ clearTimeout(this.syncRetryTimer);
641
+ this.syncRetryTimer = undefined;
642
+ }
643
+ this.syncRetried = false;
644
+ this.pendingDelays.forEach((timer) => clearTimeout(timer));
645
+ this.pendingDelays.clear();
646
+ }
647
+
648
+ /**
649
+ * `inApp.syncInterval`: ask the server again every N ms, so a campaign
650
+ * published while the visitor sits on one page still reaches them. Off by
651
+ * default, skipped while the tab is hidden, and never in
652
+ * `session-start-only` mode.
653
+ */
654
+ private startPeriodicSync(): void {
655
+ if (this.periodicSyncTimer) {
656
+ clearInterval(this.periodicSyncTimer);
657
+ this.periodicSyncTimer = undefined;
658
+ }
659
+ if (this.runtime.evaluationMode !== 'differential-sync' || !(this.runtime.syncIntervalMs > 0)) return;
660
+ this.periodicSyncTimer = setInterval(() => {
661
+ if (typeof document !== 'undefined' && document.hidden) return;
662
+ if (!this.runtime.canSend()) return;
663
+ this.sync().catch((error) => this.logger.debug('Periodic in-app sync failed:', error));
664
+ }, this.runtime.syncIntervalMs);
665
+ }
666
+
667
+ /**
668
+ * Who this device is, for every in-app request: `userId` only once
669
+ * identified, `anonymousId` always.
670
+ *
671
+ * Never an email as `externalId`. The old code sent
672
+ * `externalId = attributes.externalId || attributes.email || ...`; the server
673
+ * looks a contact up by EXACT externalId, so the moment a site called
674
+ * setAttributes({ email }) every sync matched nobody and returned no
675
+ * campaigns, and every impression was dropped.
676
+ */
677
+ private subject(): { userId?: string; anonymousId: string } {
678
+ const userId = this.identity.getUserId();
679
+ return { ...(userId ? { userId } : {}), anonymousId: this.identity.getAnonymousId() };
680
+ }
681
+
682
+ /**
683
+ * If a raw in-app fetch was rejected with a 401 `sdk_authentication_error`,
684
+ * fire the host refresh callback so the NEXT sync/track carries a fresh token.
685
+ * In-app requests are polled/best-effort, so we deliberately do NOT inline-retry
686
+ * here — the refreshed token flows through on the following call.
687
+ */
688
+ private async maybeHandleSdkAuthError(response: Response, endpoint: string, subjectUserId: string | null): Promise<void> {
689
+ if (!this.auth || response.status !== 401) {
690
+ return;
691
+ }
692
+ // A request of a user who has since logged out (a dismissal tracked
693
+ // during reset(), say) must never make the host sign out the person now
694
+ // using the app (contract item 39).
695
+ const current = this.auth.getCurrentUserId ? this.auth.getCurrentUserId() : this.identity.getUserId();
696
+ if ((subjectUserId || null) !== (current || null)) return;
697
+ let data: any = null;
698
+ try {
699
+ data = await response.clone().json();
700
+ } catch {
701
+ return;
702
+ }
703
+ if (!this.auth.isSdkAuthError(response.status, data)) {
704
+ return;
705
+ }
706
+ this.logger.warn(`In-app request rejected by SDK authentication (${data?.reason || 'invalid'}) on ${endpoint} – refreshing token`);
707
+ try {
708
+ await this.auth.refreshSdkAuthToken({
709
+ reason: data?.reason || 'invalid',
710
+ endpoint,
711
+ userId: subjectUserId,
712
+ });
713
+ } catch (err) {
714
+ this.logger.error('Failed to refresh SDK authentication token for in-app request:', err);
715
+ }
716
+ }
717
+
718
+ /**
719
+ * Sync eligible campaigns from server
720
+ */
721
+ async sync(attributeChanges?: Record<string, any>): Promise<InAppSyncResponse> {
722
+ if (!this.runtime.canSend()) {
723
+ return { success: false, campaigns: [], evaluatedAt: new Date().toISOString() };
724
+ }
725
+ if (this.isSyncing) {
726
+ // Not dropped: the in-flight answer may already be out of date (an
727
+ // identify, a reset), so one more sync runs right after it.
728
+ this.syncAgain = true;
729
+ if (attributeChanges) this.syncAgainChanges = { ...(this.syncAgainChanges || {}), ...attributeChanges };
730
+ return { success: false, campaigns: [], evaluatedAt: new Date().toISOString() };
731
+ }
732
+
733
+ this.isSyncing = true;
734
+ const generation = this.generation;
735
+ this.logger.debug('Syncing in-app campaigns');
736
+
737
+ const sessionId = this.session.getSessionId();
738
+ const userAttributes = this.identity.getAttributes();
739
+
740
+ const controller = new AbortController();
741
+ const timeoutMs = this.runtime.requestTimeoutMs;
742
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
743
+
744
+ const subjectUser = this.identity.getUserId();
745
+ try {
746
+ const response = await fetch(`${this.apiEndpoint}/v1/in-app/sync`, {
747
+ method: 'POST',
748
+ headers: {
749
+ 'Content-Type': 'application/json',
750
+ 'X-SDK-Key': this.sdkKey,
751
+ ...(this.auth?.getSdkAuthHeaders(subjectUser) || {}),
752
+ },
753
+ body: JSON.stringify({
754
+ ...this.subject(),
755
+ sessionId,
756
+ userAttributes: attributeChanges || userAttributes,
757
+ // Only what the server has not acknowledged. Its PRESENCE (even
758
+ // empty) tells the backend this SDK has a durable attribute queue,
759
+ // so the stored profile is authoritative and only these few keys may
760
+ // override it - a device's cache must not out-vote the database.
761
+ pendingAttributes: this.identity.pending.pending(this.identity.getAnonymousId()),
762
+ deviceType: 'web',
763
+ locale: navigator.language,
764
+ capabilities: this.capabilities(),
765
+ }),
766
+ signal: controller.signal,
767
+ });
768
+
769
+ if (!response.ok) {
770
+ this.runtime.noteStatus?.(response.status, null);
771
+ // On a 401 SDK-auth error, trigger a token refresh so the NEXT sync
772
+ // carries a valid token (in-app sync is polled, so no inline retry).
773
+ await this.maybeHandleSdkAuthError(response, `${this.apiEndpoint}/v1/in-app/sync`, subjectUser);
774
+ throw new Error(`Sync failed: ${response.status} ${response.statusText}`);
775
+ }
776
+
777
+ const data: InAppSyncResponse = await response.json();
778
+ if (generation !== this.generation) {
779
+ // Asked for someone who is no longer this visitor (reset, identify,
780
+ // consent change meanwhile): discarded; the follow-up sync asks again.
781
+ this.logger.debug('Discarding an in-app sync answer that predates an identity or consent change');
782
+ this.syncAgain = true;
783
+ return { success: false, campaigns: [], evaluatedAt: new Date().toISOString() };
784
+ }
785
+ if (!data || data.success === false || !Array.isArray(data.campaigns)) {
786
+ // A 2xx that did not do what was asked (contract addendum 15).
787
+ throw new Error('Sync failed: the server reported success: false');
788
+ }
789
+ this.syncRetried = false;
790
+
791
+ // Update campaigns cache
792
+ this.campaigns.clear();
793
+ for (const campaign of data.campaigns) {
794
+ this.campaigns.set(campaign.id, campaign);
795
+
796
+ // Track watched attributes for differential sync
797
+ if (campaign.evaluationMode === 'differential-sync' && campaign.watchedAttributes) {
798
+ campaign.watchedAttributes.forEach((attr) => this.boundedSetAdd(this.watchedAttributes, attr, MAX_WATCHED_ATTRIBUTES));
799
+ }
800
+ }
801
+
802
+ this.logger.info(`Synced ${data.campaigns.length} eligible campaigns`);
803
+
804
+ // Process immediate triggers
805
+ this.processImmediateTriggers();
806
+
807
+ return data;
808
+ } catch (error) {
809
+ const timedOut = (error as any)?.name === 'AbortError';
810
+ this.logger.error(timedOut ? `Campaign sync timed out after ${timeoutMs}ms` : 'Failed to sync campaigns:', error);
811
+
812
+ // Retry ONCE after 5 seconds on failure. The handle is kept so stop() /
813
+ // reset() / destroy() can cancel it - it used to outlive all three.
814
+ //
815
+ // `syncRetried` is cleared by a SUCCESSFUL sync (or stop()), never by
816
+ // the retry itself: the timer used to reset it before retrying, so a
817
+ // persistently failing API was asked again every 5 seconds for as long
818
+ // as the page stayed open.
819
+ if (generation === this.generation && !this.syncRetried && this.runtime.canSend()) {
820
+ this.syncRetried = true;
821
+ this.syncRetryTimer = setTimeout(() => {
822
+ this.syncRetryTimer = undefined;
823
+ this.sync().catch(() => {});
824
+ }, 5000);
825
+ }
826
+
827
+ throw timedOut ? new Error('Campaign sync timed out') : error;
828
+ } finally {
829
+ clearTimeout(timeoutId);
830
+ this.isSyncing = false;
831
+ if (this.syncAgain) {
832
+ this.syncAgain = false;
833
+ const changes = this.syncAgainChanges;
834
+ this.syncAgainChanges = undefined;
835
+ if (this.runtime.canSend()) {
836
+ // A full sync when the audience changed, a differential one otherwise.
837
+ this.sync(generation !== this.generation ? undefined : changes).catch((error) =>
838
+ this.logger.debug('Follow-up in-app sync failed:', error));
839
+ }
840
+ }
841
+ }
842
+ }
843
+
844
+ /**
845
+ * Process campaigns with immediate triggers
846
+ */
847
+ private processImmediateTriggers(): void {
848
+ for (const campaign of this.campaigns.values()) {
849
+ const triggers = campaign.triggers || [];
850
+ const hasImmediateTrigger = triggers.some((t) => t.type === 'immediate');
851
+ if (hasImmediateTrigger) {
852
+ this.displayCampaign(campaign).catch((error) =>
853
+ this.logger.error('In-app display failed:', error));
854
+ }
855
+ }
856
+ }
857
+
858
+ /**
859
+ * An event just happened in the app - show anything waiting on it.
860
+ *
861
+ * The server sends five trigger types and, until now, `event` was evaluated
862
+ * by NOBODY. A campaign set to "show when the user does add_to_cart" waited
863
+ * for the event to upload and the next sync to decide, so it appeared on the
864
+ * user's next visit rather than at the moment they did the thing.
865
+ *
866
+ * Everything needed was already on the device: the sync response carries the
867
+ * triggers and the content. Only the wire from track() was missing.
868
+ *
869
+ * NO eligibility re-check here. The server sent this campaign because the
870
+ * user matched it; re-deciding on the device can only subtract, using data
871
+ * the device does not have - which is exactly how Android came to suppress
872
+ * every segment-targeted campaign. The device evaluates only what the server
873
+ * cannot know: did this event just fire, and does it match the trigger's own
874
+ * property filter.
875
+ */
876
+ onEventTracked(eventName: string, properties?: Record<string, any>): void {
877
+ if (!eventName) return;
878
+ for (const campaign of this.campaigns.values()) {
879
+ const match = (campaign.triggers || []).find(
880
+ (t) => t.type === 'event' && t.event === eventName && this.triggerFilterMatches(t, properties),
881
+ );
882
+ if (match) {
883
+ this.displayCampaign(campaign).catch((error) =>
884
+ this.logger.error('In-app display failed:', error));
885
+ // One per event: a single action should not stack modals.
886
+ return;
887
+ }
888
+ }
889
+ }
890
+
891
+ /**
892
+ * A trigger's own property condition, e.g. "add_to_cart where value > 100".
893
+ *
894
+ * A trigger with no condition matches on the event name alone. An UNKNOWN
895
+ * operator does NOT match - failing closed, because showing the wrong people
896
+ * a message is worse than showing nobody, and a silent match would make a
897
+ * typo look like it worked.
898
+ */
899
+ private triggerFilterMatches(trigger: InAppTrigger, properties?: Record<string, any>): boolean {
900
+ if (!trigger.attribute) return true;
901
+ const actual = properties?.[trigger.attribute];
902
+ const expected = trigger.value;
903
+ switch (trigger.operator) {
904
+ case undefined:
905
+ case 'equals':
906
+ case 'eq':
907
+ return actual === expected;
908
+ case 'not_equals':
909
+ case 'neq':
910
+ return actual !== expected;
911
+ case 'greater_than':
912
+ case 'gt':
913
+ return Number(actual) > Number(expected);
914
+ case 'less_than':
915
+ case 'lt':
916
+ return Number(actual) < Number(expected);
917
+ case 'contains':
918
+ return String(actual ?? '').includes(String(expected));
919
+ case 'exists':
920
+ return actual !== undefined && actual !== null;
921
+ default:
922
+ return false;
923
+ }
924
+ }
925
+
926
+ /**
927
+ * Fetch a re-check campaign's content, re-verifying eligibility server-side.
928
+ *
929
+ * Returns null when the user is no longer eligible OR the request fails.
930
+ * Both mean "show nothing": a campaign in this mode is one the marketer
931
+ * said must not display on stale information, so falling back to anything
932
+ * cached would defeat the setting they chose.
933
+ */
934
+ private async resolveForDisplay(campaign: InAppCampaign): Promise<InAppCampaign | null> {
935
+ if (!this.runtime.canSend()) return null;
936
+ const generation = this.generation;
937
+ const controller = new AbortController();
938
+ const timeoutId = setTimeout(() => controller.abort(), this.runtime.requestTimeoutMs);
939
+ try {
940
+ const res = await fetch(`${this.apiEndpoint}/v1/in-app/resolve`, {
941
+ method: 'POST',
942
+ headers: {
943
+ 'Content-Type': 'application/json',
944
+ 'X-SDK-Key': this.sdkKey,
945
+ ...(this.auth?.getSdkAuthHeaders(this.identity.getUserId()) || {}),
946
+ },
947
+ body: JSON.stringify({
948
+ ...this.subject(),
949
+ campaignId: campaign.id,
950
+ sessionId: this.session.getSessionId(),
951
+ userAttributes: this.identity.getAttributes(),
952
+ pendingAttributes: this.identity.pending.pending(this.identity.getAnonymousId()),
953
+ deviceType: 'web',
954
+ }),
955
+ signal: controller.signal,
956
+ });
957
+ if (!res.ok) {
958
+ this.runtime.noteStatus?.(res.status, null);
959
+ return null;
960
+ }
961
+ const data = await res.json();
962
+ // Answered for a visitor who is no longer this one: show nothing.
963
+ if (generation !== this.generation) return null;
964
+ return data?.eligible && data.campaign ? (data.campaign as InAppCampaign) : null;
965
+ } catch {
966
+ return null;
967
+ } finally {
968
+ clearTimeout(timeoutId);
969
+ }
970
+ }
971
+
972
+ /**
973
+ * Display a campaign
974
+ */
975
+ /**
976
+ * Whether the browser can still be prompted for web push — i.e. the Notification
977
+ * permission is 'default' and the API exists. Used to gate push primers: there's
978
+ * no point showing an "Enable notifications" message once permission is granted
979
+ * (already subscribed) or denied (the browser won't re-prompt).
980
+ */
981
+ private canPromptForPush(): boolean {
982
+ try {
983
+ return typeof Notification !== 'undefined' && Notification.permission === 'default';
984
+ } catch {
985
+ return false;
986
+ }
987
+ }
988
+
989
+ async displayCampaign(campaign: InAppCampaign): Promise<void> {
990
+ // Claimed BEFORE the first await (contract item 27). The "already shown?"
991
+ // check used to sit after the re-check and render awaits, so two triggers
992
+ // in one burst (an event fired twice, a re-sync's immediate trigger) both
993
+ // passed it and the message appeared - and was counted - twice.
994
+ const id = campaign.id;
995
+ if (this.claimed.has(id) || this.displayedMessages.has(id) || this.pendingDelays.has(id)) {
996
+ this.logger.debug(`Campaign ${id} already displayed, scheduled or being prepared`);
997
+ return;
998
+ }
999
+ this.claimed.add(id);
1000
+ const generation = this.generation;
1001
+ try {
1002
+ // Re-check mode: this campaign arrived WITHOUT content. Ask the server,
1003
+ // which re-verifies eligibility and renders personalization now. Nothing
1004
+ // displays if that fails - the marketer chose this mode precisely so the
1005
+ // message never shows on stale information.
1006
+ if (campaign.reevaluateBeforeDisplay) {
1007
+ const resolved = await this.resolveForDisplay(campaign);
1008
+ if (!resolved) {
1009
+ this.logger.info(`Campaign ${campaign.id} not displayed: re-check said no`);
1010
+ return;
1011
+ }
1012
+ // Continue with the SERVER's version - freshly rendered, freshly checked.
1013
+ campaign = resolved;
1014
+ }
1015
+
1016
+ // Check if already displayed - or already scheduled. A campaign waiting
1017
+ // out its display delay is not in displayedMessages yet, so a second
1018
+ // trigger (a re-sync's immediate trigger, a repeated event) scheduled a
1019
+ // second timer and the message appeared twice.
1020
+ if (this.displayedMessages.has(campaign.id) || this.pendingDelays.has(campaign.id)) {
1021
+ this.logger.debug(`Campaign ${campaign.id} already displayed or scheduled`);
1022
+ return;
1023
+ }
1024
+
1025
+ // Check frequency cap
1026
+ if (!this.checkFrequencyCap(campaign)) {
1027
+ this.logger.debug(`Campaign ${campaign.id} blocked by frequency cap`);
1028
+ return;
1029
+ }
1030
+
1031
+ // Render template
1032
+ const renderedHtml = await this.renderTemplate(campaign);
1033
+
1034
+ // The visitor changed while this was being prepared (reset, identify,
1035
+ // consent): it was the previous audience's message.
1036
+ if (generation !== this.generation || !this.runtime.canSend()) {
1037
+ this.logger.debug(`Campaign ${campaign.id} not displayed: the visitor or consent changed meanwhile`);
1038
+ return;
1039
+ }
1040
+
1041
+ /**
1042
+ * Nothing will appear → do not claim anything did.
1043
+ *
1044
+ * Everything that can stop this campaign reaching the screen is decided
1045
+ * HERE, before commitDisplay(), because commitDisplay is the point of no
1046
+ * return: it consumes the frequency cap (so the campaign is 'seen' and a
1047
+ * later, valid payload is suppressed) and reports an `in_app.displayed`
1048
+ * impression. A refusal that happens after it makes a delivery failure look
1049
+ * like a successful delivery in reporting, so nobody goes looking.
1050
+ * Analytics that quietly counts non-events is worse than analytics that
1051
+ * counts nothing.
1052
+ *
1053
+ * This gate used to be `if (!renderedHtml.trim())`, which is the WORKAROUND
1054
+ * for the property, not the property - and both of its errors shipped:
1055
+ *
1056
+ * - A native campaign has no HTML by construction, so it was
1057
+ * indistinguishable from a broken payload and returned here. The native
1058
+ * branch in display() was therefore unreachable: native in-app messages
1059
+ * never displayed on web at all.
1060
+ * - An HTML campaign in an integration that has not set
1061
+ * allowHtmlJsInAppMessages renders fine and is refused later, inside
1062
+ * display() - i.e. AFTER the cap was burned and the impression tracked.
1063
+ * QA saw exactly that: displayed:true, markerInDom:false.
1064
+ *
1065
+ * So the question asked is the real one: can this campaign appear?
1066
+ * Returning WITHOUT recording anything leaves it eligible, so a corrected
1067
+ * payload - or an integration that later opts in - displays normally on the
1068
+ * next evaluation instead of being permanently capped out.
1069
+ */
1070
+ const blocked = this.undisplayableReason(campaign, renderedHtml);
1071
+ if (blocked) {
1072
+ this.logger.warn(
1073
+ `Campaign ${campaign.id} not displayed (${blocked}). No impression ` +
1074
+ 'recorded and no frequency cap consumed, so it stays eligible.',
1075
+ );
1076
+ return;
1077
+ }
1078
+
1079
+ // A push primer (a message whose CTA requests notification permission) is only
1080
+ // useful while the browser can still prompt — i.e. Notification.permission is
1081
+ // 'default'. If the visitor already granted (they're subscribed) or denied
1082
+ // (the browser won't re-prompt), or push isn't supported, the message is a
1083
+ // dead-end, so suppress it — don't nag a subscribed user with "Enable
1084
+ // notifications" on every load. Skipped WITHOUT recording an impression so it
1085
+ // re-evaluates cleanly if the permission state later changes.
1086
+ if (renderedHtml.includes('requestPushPermission') && !this.canPromptForPush()) {
1087
+ this.logger.debug(
1088
+ `Campaign ${campaign.id} is a push primer but the browser can't be prompted ` +
1089
+ `(permission not 'default'); suppressing.`,
1090
+ );
1091
+ return;
1092
+ }
1093
+
1094
+ const message: InAppMessage = {
1095
+ campaign,
1096
+ renderedHtml,
1097
+ displayedAt: new Date(),
1098
+ };
1099
+
1100
+ /**
1101
+ * Everything that means "this was shown", in one place.
1102
+ *
1103
+ * Registering the message BEFORE the async impression-track is deliberate:
1104
+ * the content iframe's bootstrap calls back (setSize/logClick/...) on load,
1105
+ * which fires before a network trackImpression resolves, and the
1106
+ * host-bridge guards key off displayedMessages - so registering after would
1107
+ * leave the iframe rejected and stuck at height:0.
1108
+ *
1109
+ * All of it happens at DISPLAY time, including the frequency-cap
1110
+ * timestamp. Recording the cap when the message was merely SCHEDULED burns
1111
+ * an impression on a message the user never saw - which is precisely the
1112
+ * bug fixed on iOS earlier (an impression tracked before a render that then
1113
+ * failed), and my delay had just reintroduced it here.
1114
+ */
1115
+ const commitDisplay = () => {
1116
+ this.boundedMapSet(this.displayedMessages, campaign.id, message, MAX_DISPLAYED_MESSAGES);
1117
+ const timestamps = this.impressionTimestamps.get(campaign.id) || [];
1118
+ timestamps.push(Date.now());
1119
+ this.boundedMapSet(this.impressionTimestamps, campaign.id, timestamps, MAX_IMPRESSION_TIMESTAMPS);
1120
+ // Persist so 'once'/'lifetime'/'session' caps survive reloads and sessions.
1121
+ this.persistImpressionTimestamps();
1122
+ this.display(message);
1123
+ };
1124
+
1125
+ // Display delay: the campaign's `delay` is SECONDS to wait after the
1126
+ // trigger fires before showing. The server never used to send it, so this
1127
+ // was silently always 0 - a marketer's "wait 30 seconds" did nothing.
1128
+ const delayMs = Math.max(0, Number((campaign as any).delay) || 0) * 1000;
1129
+
1130
+ if (delayMs > 0) {
1131
+ const timer = setTimeout(() => {
1132
+ this.pendingDelays.delete(campaign.id);
1133
+ // Tied to WHO and WHETHER (contract item 43): an identify of another
1134
+ // user (even without reset()), an opt-out or a consent change while
1135
+ // the delay ran bumps the generation, and the message was the
1136
+ // previous audience's - or may no longer be shown at all.
1137
+ if (generation !== this.generation || !this.runtime.canSend()) {
1138
+ this.logger.debug(`Campaign ${campaign.id} not displayed after its delay: the visitor or consent changed`);
1139
+ return;
1140
+ }
1141
+ // Re-checked at display time: the cap may have been spent meanwhile
1142
+ // (manual displayInAppCampaign, another tab's impression after a reload).
1143
+ if (this.displayedMessages.has(campaign.id) || !this.checkFrequencyCap(campaign)) return;
1144
+ commitDisplay();
1145
+ this.trackImpression(campaign.id, 'displayed').catch(() => undefined);
1146
+ }, delayMs);
1147
+ // Tracked so dismiss()/clear() can cancel a message that has not appeared
1148
+ // yet, instead of it popping up after the user navigated away.
1149
+ this.pendingDelays.set(campaign.id, timer);
1150
+ return;
1151
+ }
1152
+
1153
+ commitDisplay();
1154
+
1155
+ // Track impression (fire-and-forget analytics; the message is already shown)
1156
+ await this.trackImpression(campaign.id, 'displayed');
1157
+ } finally {
1158
+ this.claimed.delete(id);
1159
+ }
1160
+ }
1161
+
1162
+ /**
1163
+ * The HTML to display.
1164
+ *
1165
+ * No Liquid pass: the server resolves personalisation before sending and
1166
+ * withholds a campaign whose tags did not resolve. Rendering here as well
1167
+ * was how web and mobile ended up showing different content for the same
1168
+ * campaign - and how web showed raw `{{ }}` tags whenever the server HAD
1169
+ * rendered (it set liquidEnabled:false, which this read as "no Liquid here").
1170
+ *
1171
+ * The client-side Liquid engine is gone entirely - it had no other caller,
1172
+ * and removing it drops the liquidjs dependency from a bundle that ships on
1173
+ * customer sites. It also removes one source of per-channel filter drift:
1174
+ * there is now exactly one Liquid implementation, server-side.
1175
+ */
1176
+ private async renderTemplate(campaign: InAppCampaign): Promise<string> {
1177
+ const content = campaign.content;
1178
+ if (!content || content.kind !== 'html') {
1179
+ // Not an error: a native campaign has no HTML by design and is rendered
1180
+ // as DOM by display(). Only `undisplayableReason` decides what is wrong.
1181
+ return '';
1182
+ }
1183
+ return content.html;
1184
+ }
1185
+
1186
+ /**
1187
+ * Why this campaign cannot appear on screen, or null if it can.
1188
+ *
1189
+ * ONE predicate, so the pre-commit gate in displayCampaign() and the refusal
1190
+ * inside display() cannot drift apart. They did: display() refused HTML when
1191
+ * allowHtmlJsInAppMessages was false, but the gate before it only checked
1192
+ * for empty HTML - so that refusal ran after the cap and impression were
1193
+ * already committed.
1194
+ */
1195
+ private undisplayableReason(campaign: InAppCampaign, renderedHtml: string): string | null {
1196
+ const kind = campaign.content?.kind;
1197
+
1198
+ if (!campaign.content) return 'the payload carries no `content`';
1199
+
1200
+ // Rendered as DOM by this SDK's own views - no interpreter, no opt-in
1201
+ // needed, and no HTML expected.
1202
+ if (kind === 'native') return null;
1203
+
1204
+ if (kind === 'html') {
1205
+ if (!renderedHtml.trim()) return 'its `content.html` is empty';
1206
+ // A SKIP, not a failure: HTML runs author-supplied JavaScript on the host
1207
+ // page, so an integration that has not opted in never creates the iframe.
1208
+ if (!this.allowHtmlJsInAppMessages) {
1209
+ return 'it is an HTML message and allowHtmlJsInAppMessages is false';
1210
+ }
1211
+ return null;
1212
+ }
1213
+
1214
+ return `this SDK cannot render content kind '${String(kind)}'`;
1215
+ }
1216
+
1217
+
1218
+
1219
+ /**
1220
+ * Display message in DOM
1221
+ */
1222
+ private display(message: InAppMessage): void {
1223
+ const { campaign, renderedHtml } = message;
1224
+
1225
+ // Create container
1226
+ const container = document.createElement('div');
1227
+ container.id = `joryio-inapp-${campaign.id}`;
1228
+ // `displayOptions` (inApp.displayOptions). All five used to be resolved
1229
+ // and then ignored by this renderer, so setting them did nothing:
1230
+ // - defaultPosition: where a message whose type has no placement of its
1231
+ // own (`custom`) sits - modal/banner/slideup/fullscreen keep theirs;
1232
+ // - defaultAnimation: how every message enters (fade, slide or none);
1233
+ // - backdropColor / closeButtonColor / zIndex: as named.
1234
+ const position = BUILT_IN_POSITIONS.has(campaign.type)
1235
+ ? null
1236
+ : (['top', 'center', 'bottom'] as const).find((p) => p === this.displayOptions.defaultPosition) || 'center';
1237
+ const animation = (['fade', 'slide', 'none'] as const).find((a) => a === this.displayOptions.defaultAnimation) || 'fade';
1238
+ container.className =
1239
+ `joryio-inapp joryio-inapp-${campaign.type}` +
1240
+ (position ? ` joryio-inapp-pos-${position}` : '') +
1241
+ (animation !== 'none' ? ` joryio-inapp-anim-${animation}` : '');
1242
+
1243
+ // Create style element (with id for cleanup on dismiss)
1244
+ const style = document.createElement('style');
1245
+ style.id = `joryio-style-${campaign.id}`;
1246
+ // Configurable, because 999999 is not always enough: a site whose own header
1247
+ // or cookie banner sits higher would render the message behind it, and the
1248
+ // option to fix that existed in config while being ignored here.
1249
+ const zIndex = Number.isFinite(this.displayOptions.zIndex) ? Number(this.displayOptions.zIndex) : 999999;
1250
+ const backdropColor = cssValue(this.displayOptions.backdropColor) || 'rgba(0, 0, 0, 0.5)';
1251
+ const closeButtonColor = cssValue(this.displayOptions.closeButtonColor) || 'inherit';
1252
+ style.textContent = `
1253
+ .joryio-inapp {
1254
+ position: fixed;
1255
+ z-index: ${zIndex};
1256
+ }
1257
+
1258
+ /* displayOptions.defaultPosition - only for types with no placement of their own. */
1259
+ .joryio-inapp-pos-top {
1260
+ top: 20px;
1261
+ left: 50%;
1262
+ transform: translateX(-50%);
1263
+ max-width: 90%;
1264
+ }
1265
+ .joryio-inapp-pos-center {
1266
+ top: 50%;
1267
+ left: 50%;
1268
+ transform: translate(-50%, -50%);
1269
+ max-width: 90%;
1270
+ max-height: 90%;
1271
+ }
1272
+ .joryio-inapp-pos-bottom {
1273
+ bottom: 20px;
1274
+ left: 50%;
1275
+ transform: translateX(-50%);
1276
+ max-width: 90%;
1277
+ }
1278
+
1279
+ /* displayOptions.defaultAnimation. The individual \`translate\` property
1280
+ composes with the positioning \`transform\` above instead of
1281
+ replacing it. Motion is skipped for visitors who asked for less. */
1282
+ @keyframes joryio-inapp-fade-in { from { opacity: 0; } to { opacity: 1; } }
1283
+ @keyframes joryio-inapp-slide-in { from { opacity: 0; translate: 0 16px; } to { opacity: 1; translate: 0 0; } }
1284
+ .joryio-inapp-anim-fade { animation: joryio-inapp-fade-in 200ms ease-out both; }
1285
+ .joryio-inapp-anim-slide { animation: joryio-inapp-slide-in 240ms ease-out both; }
1286
+ @media (prefers-reduced-motion: reduce) {
1287
+ .joryio-inapp-anim-fade, .joryio-inapp-anim-slide { animation: none; }
1288
+ }
1289
+
1290
+ /* The message content lives in a sandboxed iframe; its own CSS stays
1291
+ inside it (no leak to/from the host page). Only SDK chrome is here. */
1292
+ .joryio-inapp > iframe {
1293
+ border: 0;
1294
+ width: 100%;
1295
+ display: block;
1296
+ background: transparent;
1297
+ color-scheme: normal;
1298
+ }
1299
+
1300
+ .joryio-inapp-modal {
1301
+ top: 50%;
1302
+ left: 50%;
1303
+ transform: translate(-50%, -50%);
1304
+ max-width: 90%;
1305
+ max-height: 90%;
1306
+ }
1307
+
1308
+ .joryio-inapp-banner {
1309
+ top: 0;
1310
+ left: 0;
1311
+ right: 0;
1312
+ }
1313
+
1314
+ .joryio-inapp-slideup {
1315
+ bottom: 20px;
1316
+ right: 20px;
1317
+ max-width: 400px;
1318
+ }
1319
+
1320
+ .joryio-inapp-fullscreen {
1321
+ top: 0;
1322
+ left: 0;
1323
+ right: 0;
1324
+ bottom: 0;
1325
+ }
1326
+
1327
+ /* Native card. Deliberately minimal and system-font: the host page's
1328
+ typography is the closest thing the web has to "the app's look", and
1329
+ a shipped palette would clash with every site.
1330
+
1331
+ Every author-settable value comes through a CUSTOM PROPERTY with the
1332
+ system default as its fallback. That is what lets the campaign's own
1333
+ CSS override the colour pickers: inline styles beat a stylesheet, so
1334
+ setting the pickers inline would have made "advanced" strictly weaker
1335
+ than "basic" - the opposite of what an escape hatch is for. */
1336
+ .joryio-inapp-native {
1337
+ /* Positioning context for the close button, which sits in the card's
1338
+ top trailing corner rather than in the content flow. */
1339
+ position: relative;
1340
+ background: var(--joryio-inapp-bg, Canvas);
1341
+ color: var(--joryio-inapp-fg, CanvasText);
1342
+ color-scheme: light dark;
1343
+ border-radius: var(--joryio-inapp-radius, 14px);
1344
+ padding: 20px;
1345
+ box-shadow: 0 10px 40px rgba(0,0,0,0.18);
1346
+ font: menu;
1347
+ font-family: var(--joryio-inapp-font, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif);
1348
+ /* Direction-relative, so a Hebrew or Arabic message reads from the
1349
+ right without the campaign having to know which way it runs. The
1350
+ card carries dir=auto, so 'start' resolves per message. */
1351
+ text-align: var(--joryio-inapp-align, start);
1352
+ max-width: 420px;
1353
+ }
1354
+ .joryio-inapp-fullscreen .joryio-inapp-native {
1355
+ border-radius: 0;
1356
+ max-width: none;
1357
+ height: 100%;
1358
+ }
1359
+ .joryio-inapp-native img {
1360
+ width: 100%;
1361
+ height: 140px;
1362
+ object-fit: cover;
1363
+ border-radius: 8px;
1364
+ display: block;
1365
+ margin-bottom: 14px;
1366
+ }
1367
+ .joryio-inapp-native h2 {
1368
+ margin: 0 0 8px;
1369
+ /* Room for the close button in the trailing corner. Logical, so in a
1370
+ Hebrew message the gap is on the left, where the button is. */
1371
+ padding-inline-end: 28px;
1372
+ /* Derived from the body size so one control keeps the hierarchy. */
1373
+ font-size: calc(var(--joryio-inapp-size, 14.5px) * 1.3);
1374
+ font-weight: var(--joryio-inapp-title-weight, 700);
1375
+ line-height: 1.3;
1376
+ }
1377
+ .joryio-inapp-native p {
1378
+ margin: 0 0 16px;
1379
+ font-size: var(--joryio-inapp-size, 14.5px);
1380
+ line-height: 1.5;
1381
+ opacity: 0.85;
1382
+ }
1383
+ .joryio-inapp-buttons {
1384
+ display: flex;
1385
+ gap: 8px;
1386
+ }
1387
+ .joryio-inapp-buttons.stacked {
1388
+ flex-direction: column;
1389
+ }
1390
+ .joryio-inapp-native button {
1391
+ flex: 1;
1392
+ min-height: 44px;
1393
+ border-radius: 8px;
1394
+ font: inherit;
1395
+ font-size: 14px;
1396
+ cursor: pointer;
1397
+ border: 1px solid transparent;
1398
+ }
1399
+ .joryio-inapp-native button.primary {
1400
+ background: var(--joryio-inapp-primary, AccentColor);
1401
+ color: var(--joryio-inapp-primary-fg, AccentColorText);
1402
+ }
1403
+ .joryio-inapp-native button.secondary {
1404
+ background: transparent;
1405
+ color: inherit;
1406
+ border-color: rgba(128,128,128,0.4);
1407
+ }
1408
+ /*
1409
+ * Native messages used to override the shared close button into a
1410
+ * full-width "Close" text row under the buttons, while HTML messages in
1411
+ * this same SDK drew an ✕ in the corner. One SDK, two dismiss designs -
1412
+ * and the text row also hardcoded the English word "Close", which is
1413
+ * wrong in a Hebrew or Arabic app. The ✕ needs no translation, so native
1414
+ * now uses the shared button and this override is gone.
1415
+ */
1416
+
1417
+ .joryio-inapp-backdrop {
1418
+ position: fixed;
1419
+ top: 0;
1420
+ left: 0;
1421
+ right: 0;
1422
+ bottom: 0;
1423
+ background: ${backdropColor};
1424
+ z-index: ${zIndex - 1};
1425
+ }
1426
+
1427
+ .joryio-inapp-close {
1428
+ position: absolute;
1429
+ top: 6px;
1430
+ /* inset-inline-end, not right: the card carries dir=auto, so a Hebrew
1431
+ or Arabic message puts the ✕ in ITS trailing corner - the left one -
1432
+ without the campaign having to know which way the text runs. */
1433
+ inset-inline-end: 6px;
1434
+ display: flex;
1435
+ align-items: center;
1436
+ justify-content: center;
1437
+ /* 32px is the tap target; the glyph inside is smaller. Anything less
1438
+ is hard to hit on a phone, and it is the only way out of a
1439
+ full-screen message. */
1440
+ width: 32px;
1441
+ height: 32px;
1442
+ padding: 0;
1443
+ cursor: pointer;
1444
+ background: transparent;
1445
+ border: none;
1446
+ border-radius: 50%;
1447
+ font: inherit;
1448
+ font-size: 22px;
1449
+ line-height: 1;
1450
+ opacity: 0.55;
1451
+ color: ${closeButtonColor};
1452
+ }
1453
+ .joryio-inapp-close:hover,
1454
+ .joryio-inapp-close:focus-visible {
1455
+ opacity: 1;
1456
+ }
1457
+ `;
1458
+
1459
+ // Render the message HTML inside a SAME-ORIGIN SANDBOXED iframe. The sandbox
1460
+ // (allow-scripts allow-same-origin) is the isolation/encapsulation boundary
1461
+ // — message CSS can't leak to the host and host CSS can't bleed in — and it
1462
+ // lets the message's own JS run AND reach `joryioBridge`. Same-origin is
1463
+ // required so `joryioBridge.requestPushPermission()` keeps the click's
1464
+ // user-gesture activation (a cross-origin postMessage hop would lose it).
1465
+ // Content is first-party (authored in-dashboard + sanitized server-side on
1466
+ // save); the sandbox is defense-in-depth, not the only line.
1467
+ const iframe = document.createElement('iframe');
1468
+ iframe.id = `joryio-iframe-${campaign.id}`;
1469
+ // ── Native content: build DOM directly, no iframe ───────────────────────
1470
+ //
1471
+ // The iframe exists to contain UNTRUSTED MARKUP - authored HTML with its
1472
+ // own scripts and styles. Native content is data: strings, a URL and an
1473
+ // action enum. There is nothing to sandbox, so wrapping it in an iframe
1474
+ // would add the srcdoc round-trip, the height-reporting shim and the bridge
1475
+ // hop for no benefit.
1476
+ //
1477
+ // Everything below uses textContent, never innerHTML. The server does not
1478
+ // HTML-escape native fields (escaping would show the user `A &amp; B`), so
1479
+ // innerHTML here would reintroduce exactly the injection native avoids.
1480
+ // Hand-written CSS from the campaign's advanced section, picked up in the
1481
+ // native branch below and injected (scoped) once the container exists.
1482
+ let authorCss: string | undefined;
1483
+
1484
+ if (campaign.content?.kind === 'native') {
1485
+ const native = campaign.content;
1486
+ const card = document.createElement('div');
1487
+ card.className = 'joryio-inapp-native';
1488
+ // `auto` resolves direction from the message's OWN first strong
1489
+ // character, per message. Not the page's dir and not the app's locale:
1490
+ // one workspace sends Hebrew and English from the same campaign list, so
1491
+ // anything but the text itself gets one of them wrong.
1492
+ //
1493
+ // On the card rather than on each label, because a flex row inherits it:
1494
+ // the primary button lands on the right in Hebrew without a second rule.
1495
+ card.dir = 'auto';
1496
+
1497
+ // Author overrides, set as CUSTOM PROPERTIES rather than as direct inline
1498
+ // styles. Both are inline, but a custom property only feeds the
1499
+ // stylesheet rule above - so campaign CSS can still override it, while an
1500
+ // inline `background` could only be beaten with !important. Each is
1501
+ // optional: unset leaves the system default, which is what makes an
1502
+ // unstyled message look like it belongs on the page rather than carrying
1503
+ // our palette onto it.
1504
+ const st = (native as { style?: Record<string, unknown> }).style;
1505
+ if (typeof st?.customCss === 'string') authorCss = st.customCss;
1506
+ if (st) {
1507
+ const token = (name: string, value: unknown, suffix = '') => {
1508
+ if (typeof value === 'string' && value.trim()) {
1509
+ card.style.setProperty(name, value.trim() + suffix);
1510
+ } else if (typeof value === 'number' && Number.isFinite(value)) {
1511
+ card.style.setProperty(name, String(value) + suffix);
1512
+ }
1513
+ };
1514
+ token('--joryio-inapp-bg', st.backgroundColor);
1515
+ token('--joryio-inapp-fg', st.textColor);
1516
+ token('--joryio-inapp-font', st.fontFamily);
1517
+ token('--joryio-inapp-radius', st.cornerRadius, 'px');
1518
+ token('--joryio-inapp-size', st.fontSize, 'px');
1519
+ if (typeof st.titleWeight === 'string') {
1520
+ const WEIGHTS: Record<string, string> = {
1521
+ regular: '400',
1522
+ medium: '500',
1523
+ semibold: '600',
1524
+ bold: '700',
1525
+ };
1526
+ token('--joryio-inapp-title-weight', WEIGHTS[st.titleWeight]);
1527
+ }
1528
+ if (typeof st.textAlign === 'string' && st.textAlign !== 'auto') {
1529
+ // `start`/`end` are already direction-relative in CSS, so they do the
1530
+ // right thing under dir="auto" without a second branch for RTL.
1531
+ token('--joryio-inapp-align', st.textAlign);
1532
+ }
1533
+ token('--joryio-inapp-primary', st.primaryButtonColor);
1534
+ // No explicit label colour: derive one from the FILL rather than
1535
+ // leaving AccentColorText, which is white on most systems. An author
1536
+ // who picks a pale brand colour and stops there would otherwise get an
1537
+ // invisible label - and it would look right in the editor preview.
1538
+ token(
1539
+ '--joryio-inapp-primary-fg',
1540
+ typeof st.primaryButtonTextColor === 'string' && st.primaryButtonTextColor.trim()
1541
+ ? st.primaryButtonTextColor
1542
+ : readableForeground(st.primaryButtonColor),
1543
+ );
1544
+ }
1545
+
1546
+ if (native.imageUrl && /^https:\/\//i.test(native.imageUrl)) {
1547
+ const img = document.createElement('img');
1548
+ img.src = native.imageUrl;
1549
+ img.alt = '';
1550
+ // A failed image hides rather than leaving a broken-image icon in the
1551
+ // middle of the message.
1552
+ img.onerror = () => img.remove();
1553
+ card.appendChild(img);
1554
+ }
1555
+
1556
+ if (native.title) {
1557
+ const h = document.createElement('h2');
1558
+ h.textContent = native.title;
1559
+ card.appendChild(h);
1560
+ }
1561
+
1562
+ const p = document.createElement('p');
1563
+ p.textContent = native.body;
1564
+ card.appendChild(p);
1565
+
1566
+ if (native.buttons?.length) {
1567
+ const row = document.createElement('div');
1568
+ row.className =
1569
+ native.buttons.length <= 2 ? 'joryio-inapp-buttons' : 'joryio-inapp-buttons stacked';
1570
+ native.buttons.forEach((b, index) => {
1571
+ const btn = document.createElement('button');
1572
+ btn.type = 'button';
1573
+ btn.textContent = b.text;
1574
+ // The author's order decides which action leads; the first is primary.
1575
+ btn.className = index === 0 ? 'primary' : 'secondary';
1576
+ btn.addEventListener('click', () => {
1577
+ // The backend's vocabulary action (`clicked`), never the button
1578
+ // id: an id like `cta` is outside the vocabulary, so the server
1579
+ // recorded "unknown_action" and the click never counted.
1580
+ const elementId = normalizedElementId(b.id);
1581
+ this.trackImpression(
1582
+ campaign.id,
1583
+ 'clicked',
1584
+ {
1585
+ ...(b.url && /^https?:\/\//i.test(b.url) ? { clickedUrl: b.url } : {}),
1586
+ ...(elementId ? { elementId } : {}),
1587
+ },
1588
+ ).catch(() => {});
1589
+ this.callHost('onMessageClick', () => this.displayOptions.onClick?.(message, b.id || b.action || 'button'));
1590
+ // The push-permission action carries no url, so the url branch
1591
+ // below skipped it and the button silently did nothing on web -
1592
+ // while iOS, Android, React Native and Unity all act on it. The
1593
+ // capability was already here for HTML messages (the bridge's
1594
+ // requestPushPermission); only the native renderer never called it.
1595
+ //
1596
+ // FIRST and synchronously: this must run inside the click handler's
1597
+ // user activation or the browser refuses to show the OS prompt, and
1598
+ // an await anywhere above it loses that activation.
1599
+ if (b.action === 'request_push_permission') {
1600
+ if (this.canPromptForPush()) {
1601
+ this.bridge?.requestPushPermission();
1602
+ }
1603
+ } else if (
1604
+ // Named actions only. `!== 'dismiss'` would have been enough
1605
+ // today but is the exact shape that let the push action through
1606
+ // the wrong branch in the first place - and a stored `dismiss`
1607
+ // button that still carries a stale url would open it.
1608
+ (b.action === 'url' || b.action === 'deep_link') &&
1609
+ b.url &&
1610
+ /^https?:\/\//i.test(b.url)
1611
+ ) {
1612
+ // http(s) only - same rule as every other renderer.
1613
+ window.open(b.url, '_blank', 'noopener');
1614
+ }
1615
+ // EVERY button dismisses, and an unknown action was already decoded
1616
+ // to dismiss server-side, so a future action type cannot leave a
1617
+ // button that does nothing.
1618
+ this.dismiss(campaign.id, 'button').catch(() => {});
1619
+ });
1620
+ row.appendChild(btn);
1621
+ });
1622
+ card.appendChild(row);
1623
+ }
1624
+
1625
+ if (native.closeButton !== false) {
1626
+ const close = document.createElement('button');
1627
+ close.type = 'button';
1628
+ close.className = 'joryio-inapp-close';
1629
+ // The glyph is decorative; the accessible name is what a screen reader
1630
+ // announces. Without aria-label an icon-only button reads as "button".
1631
+ close.innerHTML = '&times;';
1632
+ close.setAttribute('aria-label', 'Close');
1633
+ close.addEventListener('click', () => {
1634
+ this.dismiss(campaign.id, 'close_button').catch(() => {});
1635
+ });
1636
+ card.appendChild(close);
1637
+ }
1638
+
1639
+ container.appendChild(card);
1640
+ // No early return and no impression here. This branch used to append
1641
+ // the card to <body> WITHOUT the stylesheet (so it was unpositioned),
1642
+ // track a second `displayed` (commitDisplay's caller already tracks it -
1643
+ // every native impression counted twice), and return before onDisplay,
1644
+ // which therefore never fired for native messages. Native now shares the
1645
+ // mounting below with HTML.
1646
+ }
1647
+
1648
+ // Guard against multiple rapid dismiss calls (e.g., double-click)
1649
+ let isDismissing = false;
1650
+
1651
+ if (campaign.content?.kind !== 'native') {
1652
+ // HTML runs author-supplied JavaScript on this page - which is why the
1653
+ // iframe exists at all. An integration that has not opted in never creates
1654
+ // one. This is a SKIP, not a failure: native messages still display,
1655
+ // because they are data rendered as DOM with no interpreter.
1656
+ if (!this.allowHtmlJsInAppMessages) {
1657
+ this.logger.warn(
1658
+ `Campaign ${campaign.id} is an HTML in-app message, but ` +
1659
+ `allowHtmlJsInAppMessages is false - not displaying it.`,
1660
+ );
1661
+ return;
1662
+ }
1663
+
1664
+ // allow-scripts/allow-same-origin: run the message JS + reach the bridge.
1665
+ // allow-popups: a CTA may open a new tab. NOT allow-top-navigation: every
1666
+ // link and redirect goes through the bridge's `navigate`, which the HOST
1667
+ // page carries out (rule 57) - the message document itself may never
1668
+ // navigate anything.
1669
+ iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-popups');
1670
+ // Only the initial document may load (rule 57). A second `load` means the
1671
+ // frame navigated away from its srcdoc (a meta refresh, a link the
1672
+ // bootstrap did not catch): the message is removed rather than left
1673
+ // showing - and bridging - a document Joryio did not render.
1674
+ let frameLoads = 0;
1675
+ iframe.addEventListener('load', () => {
1676
+ frameLoads++;
1677
+ let onSrcdoc = true;
1678
+ try {
1679
+ const href = iframe.contentWindow?.location?.href;
1680
+ onSrcdoc = href === undefined || href === 'about:srcdoc';
1681
+ } catch {
1682
+ onSrcdoc = false; // cross-origin: it navigated
1683
+ }
1684
+ if (frameLoads > 1 || !onSrcdoc) {
1685
+ this.logger.warn(`In-app message ${campaign.id} tried to navigate its frame; it has been closed`);
1686
+ this.dismiss(campaign.id, 'navigation_blocked').catch(() => undefined);
1687
+ }
1688
+ });
1689
+ iframe.setAttribute('title', campaign.name || 'Message');
1690
+ // Start at 0 height (avoids the ~150px default-iframe flash); the bootstrap
1691
+ // reports real size on load via the bridge's setSize. Banner/fullscreen fill
1692
+ // width; modal/slideup get their content width from setSize.
1693
+ iframe.style.height = '0';
1694
+ if (campaign.type === 'banner' || campaign.type === 'fullscreen') {
1695
+ iframe.style.width = '100%';
1696
+ }
1697
+ iframe.srcdoc = this.buildSrcdoc(
1698
+ campaign.id,
1699
+ renderedHtml,
1700
+ campaign.content?.kind === 'html' ? campaign.content.css : '',
1701
+ campaign.type,
1702
+ );
1703
+ container.appendChild(iframe);
1704
+ this.iframes.set(campaign.id, iframe);
1705
+
1706
+ // If this looks like a push primer, pre-warm web push (VAPID + SW) now so the
1707
+ // later gesture-bound requestPushPermission() prompts reliably. Fire-and-forget;
1708
+ // never prompts. Only for messages that actually reference the push action, so
1709
+ // non-primer messages don't register the SW.
1710
+ if (renderedHtml.includes('requestPushPermission')) {
1711
+ this.bridge?.preparePushPermission?.();
1712
+ }
1713
+
1714
+ // Add close button if enabled (native draws its own inside the card)
1715
+ if (this.displayOptions.closeButton !== false) {
1716
+ const closeBtn = document.createElement('button');
1717
+ closeBtn.className = 'joryio-inapp-close';
1718
+ closeBtn.innerHTML = '&times;';
1719
+ closeBtn.setAttribute('aria-label', 'Close');
1720
+ closeBtn.onclick = async () => {
1721
+ if (isDismissing) return;
1722
+ isDismissing = true;
1723
+ await this.dismiss(campaign.id, 'close_button').catch(() => {});
1724
+ };
1725
+ container.appendChild(closeBtn);
1726
+ }
1727
+ }
1728
+
1729
+ // Add backdrop for modal/fullscreen
1730
+ let backdrop: HTMLElement | null = null;
1731
+ if (campaign.type === 'modal' || campaign.type === 'fullscreen') {
1732
+ backdrop = document.createElement('div');
1733
+ backdrop.className = 'joryio-inapp-backdrop';
1734
+ backdrop.id = `joryio-backdrop-${campaign.id}`;
1735
+
1736
+ const nativeBackdropDismissible =
1737
+ campaign.content?.kind !== 'native' || campaign.content.backdropDismissible !== false;
1738
+ if (this.displayOptions.backdropClose !== false && nativeBackdropDismissible) {
1739
+ backdrop.onclick = async () => {
1740
+ if (isDismissing) return;
1741
+ isDismissing = true;
1742
+ await this.dismiss(campaign.id, 'backdrop').catch(() => {});
1743
+ };
1744
+ }
1745
+ }
1746
+
1747
+ // Click tracking + CTA actions now happen INSIDE the iframe (cross-frame
1748
+ // clicks aren't visible to the host) — see buildSrcdoc's bootstrap, which
1749
+ // auto-logs a/button clicks and routes `data-joryio-action` to joryioBridge.
1750
+
1751
+ // Append to DOM. Default to <html> (documentElement), NOT <body>: a
1752
+ // position:fixed element's containing block is the viewport ONLY if no
1753
+ // ancestor has a transform/filter/perspective/will-change. Many themes
1754
+ // (e.g. RTL Elementor) put a transform on <body>, which silently makes the
1755
+ // backdrop + modal anchor to <body>'s box instead of the viewport — the
1756
+ // overlay then renders off to one side on wide screens (centered fine on
1757
+ // narrow ones, where body ≈ viewport). Anchoring to <html> sidesteps a
1758
+ // transformed <body>. An explicit displayOptions.container still wins.
1759
+ const targetContainer = this.displayOptions.container || document.documentElement;
1760
+ if (backdrop) {
1761
+ targetContainer.appendChild(backdrop);
1762
+ }
1763
+ targetContainer.appendChild(style);
1764
+
1765
+ // The advanced escape hatch: CSS the campaign author wrote by hand. It is
1766
+ // appended AFTER the base sheet so equal-specificity rules win, and every
1767
+ // selector in it is rewritten to sit under this message's container.
1768
+ //
1769
+ // The scoping is not a nicety. Native content is NOT in an iframe (there is
1770
+ // no markup to sandbox), so an unscoped `body { display: none }` typed into
1771
+ // a campaign would blank the customer's website. Scoping makes the blast
1772
+ // radius the message itself.
1773
+ if (typeof authorCss === 'string' && authorCss.trim()) {
1774
+ const scoped = this.scopeCss(authorCss, `#joryio-inapp-${campaign.id}`);
1775
+ if (scoped) {
1776
+ const custom = document.createElement('style');
1777
+ custom.id = `joryio-css-${campaign.id}`;
1778
+ custom.textContent = scoped;
1779
+ targetContainer.appendChild(custom);
1780
+ }
1781
+ }
1782
+
1783
+ targetContainer.appendChild(container);
1784
+
1785
+ // Call onDisplay callback
1786
+ this.callHost('onMessageDisplay', () => this.displayOptions.onDisplay?.(message));
1787
+
1788
+ this.logger.info(`Displayed campaign: ${campaign.name} (${campaign.id})`);
1789
+ }
1790
+
1791
+ /**
1792
+ * Build the iframe srcdoc: the message HTML + its CSS + a bootstrap that
1793
+ * (a) exposes `window.joryioBridge` (proxying same-origin to the host bridge),
1794
+ * (b) auto-routes `data-joryio-action="<method>"` clicks to it + logs a/button
1795
+ * clicks, and (c) reports content height so the host can size the iframe.
1796
+ *
1797
+ * A declarative CTA may carry ONE argument, in `data-joryio-event`. Before
1798
+ * that the router called the verb with no arguments at all, which is why
1799
+ * `logConversion` had to be pulled from the editor's dropdown: it needs an
1800
+ * event name, received `undefined`, and the host guard dropped it - a control
1801
+ * that looked like it worked and logged nothing. Verbs taking two arguments
1802
+ * (`navigate`) still cannot be driven declaratively.
1803
+ * NOT sanitized here — the sandbox is the boundary and the HTML is first-party
1804
+ * (dashboard-authored + sanitized server-side on save). campaignId + the host
1805
+ * key are injected as JSON literals (our own values).
1806
+ */
1807
+ private buildSrcdoc(
1808
+ campaignId: string,
1809
+ html: string,
1810
+ css: string,
1811
+ type: InAppCampaign['type'],
1812
+ ): string {
1813
+ const cid = JSON.stringify(campaignId);
1814
+ const key = JSON.stringify(HOST_BRIDGE_KEY);
1815
+ // Content-sized types shrink the body to its content so scrollWidth reflects
1816
+ // the authored width (the host sets the iframe to it). Full-bleed types keep
1817
+ // body at full width (the host forces the iframe to 100%, ignoring width).
1818
+ const contentSized = type === 'modal' || type === 'slideup';
1819
+ const bodySizing = contentSized ? 'body{display:inline-block;max-width:100vw;}' : '';
1820
+ const bootstrap = `(function(){
1821
+ var CID=${cid},KEY=${key};
1822
+ // Per-device visibility: tag the root with the REAL device class from the
1823
+ // HOST page viewport (the iframe is message-sized, so its own innerWidth is
1824
+ // useless here). The jr-vp-* → jr-hide-* rules in <style> then hide elements
1825
+ // the dashboard marked "Mobile only" / "Desktop only". <=480 = mobile.
1826
+ try{var __vp=0;try{__vp=window.parent.innerWidth||0;}catch(e){}if(!__vp)__vp=window.innerWidth||0;document.documentElement.className+=(__vp<=480?' jr-vp-mobile':' jr-vp-desktop');}catch(e){}
1827
+ function host(){try{return window.parent[KEY]||null;}catch(e){return null;}}
1828
+ function call(m,args){var b=host();if(b&&typeof b[m]==='function'){try{return b[m].apply(b,[CID].concat(args||[]));}catch(e){}}}
1829
+ window.joryioBridge={
1830
+ logCustomEvent:function(name,props){call('logCustomEvent',[name,props]);},
1831
+ setCustomUserAttribute:function(k,v){call('setCustomUserAttribute',[k,v]);},
1832
+ changeUser:function(uid){call('changeUser',[uid]);},
1833
+ requestPushPermission:function(){call('requestPushPermission',[]);},
1834
+ logConversion:function(ev){call('logConversion',[ev||'in_app_conversion']);},
1835
+ submitForm:function(values){return call('submitForm',[values]);},
1836
+ logClick:function(action){call('logClick',[action]);},
1837
+ navigate:function(url,target){call('navigate',[url,target]);},
1838
+ closeMessage:function(){call('closeMessage',[]);}
1839
+ };
1840
+ document.addEventListener('click',function(e){
1841
+ var el=e.target;
1842
+ while(el&&el!==document.body&&el!==document.documentElement){
1843
+ var action=el.getAttribute&&el.getAttribute('data-joryio-action');
1844
+ if(action&&typeof window.joryioBridge[action]==='function'){e.preventDefault();window.joryioBridge[action](el.getAttribute('data-joryio-event')||undefined);return;}
1845
+ var tag=el.tagName;
1846
+ if(tag==='A'||tag==='BUTTON'){
1847
+ var da=el.getAttribute('data-action');
1848
+ var href=tag==='A'?el.getAttribute('href'):null;
1849
+ window.joryioBridge.logClick(da||(href?'link_click':'cta_click'));
1850
+ // Route only http(s)/root-relative links through the host (top-nav,
1851
+ // since the sandbox can't navigate the top page itself). Leave
1852
+ // #anchors, mailto:, tel:, sms: to the browser. Any other link
1853
+ // would navigate THIS frame away from the message: cancelled (rule 57).
1854
+ if(href&&/^(https?:\\/\\/|\\/(?!\\/))/i.test(href)){e.preventDefault();window.joryioBridge.navigate(href,el.getAttribute('target'));}
1855
+ else if(href&&!/^(#|mailto:|tel:|sms:)/i.test(href)){e.preventDefault();}
1856
+ return;
1857
+ }
1858
+ el=el.parentNode;
1859
+ }
1860
+ },true);
1861
+ // Forms (data-jry-form): serialize, hand the values to the host, show the
1862
+ // answer in place. A redirect goes through navigate (the sandbox cannot).
1863
+ document.addEventListener('submit',function(ev){var f=ev.target;if(!(f&&f.tagName==='FORM'&&f.hasAttribute('data-jry-form')))return;ev.preventDefault();if(f.getAttribute('data-jry-busy'))return;
1864
+ var d={},els=f.querySelectorAll('input,select,textarea');for(var i=0;i<els.length;i++){var el=els[i],n=el.name;if(!n||el.disabled)continue;if(el.type==='checkbox'){if(!el.checked)continue;if(d[n]===undefined)d[n]=[];d[n].push(el.value||'on')}else if(el.type==='radio'){if(el.checked)d[n]=el.value}else d[n]=el.value}
1865
+ function msg(text,ok){var b=f.querySelector('[data-jry-message]');if(!b){b=document.createElement('div');b.setAttribute('data-jry-message','');b.setAttribute('role','status');f.appendChild(b)}b.textContent=text;b.className=ok?'jry-form-success':'jry-form-error';reportSize()}
1866
+ f.setAttribute('data-jry-busy','1');var btn=f.querySelector('button[type="submit"],input[type="submit"]');if(btn)btn.disabled=true;
1867
+ var p=window.joryioBridge.submitForm(d);if(!p||typeof p.then!=='function'){msg('This form cannot be sent here.',false);f.removeAttribute('data-jry-busy');if(btn)btn.disabled=false;return}
1868
+ p.then(function(r){if(r&&r.ok){if(r.redirect){window.joryioBridge.navigate(r.redirect,'_top');return}var els2=f.querySelectorAll('input,select,textarea,button');for(var j=0;j<els2.length;j++){els2[j].disabled=true}msg(r.message||'Thank you.',true)}else{msg((r&&(r.message||(r.errors&&r.errors[0])))||'Something went wrong. Please try again.',false);f.removeAttribute('data-jry-busy');if(btn)btn.disabled=false}}).catch(function(){msg('Something went wrong. Please try again.',false);f.removeAttribute('data-jry-busy');if(btn)btn.disabled=false});
1869
+ },true);
1870
+ function reportSize(){try{var b=document.body,de=document.documentElement;var w=Math.max(b?b.scrollWidth:0,de.scrollWidth);var h=Math.max(b?b.scrollHeight:0,de.scrollHeight);call('setSize',[w,h]);}catch(e){}}
1871
+ if(document.readyState!=='loading')reportSize();else document.addEventListener('DOMContentLoaded',reportSize);
1872
+ window.addEventListener('load',reportSize);
1873
+ try{if(window.ResizeObserver&&document.body){new ResizeObserver(reportSize).observe(document.body);}}catch(e){}
1874
+ })();`;
1875
+ // Per-device visibility rules (kept in sync with the editor's
1876
+ // inAppEditorPanels.VISIBILITY_RULES_CSS). The bootstrap above sets the
1877
+ // jr-vp-* root class from the real device.
1878
+ const visRules =
1879
+ '.jr-vp-mobile .jr-hide-mobile{display:none!important}.jr-vp-desktop .jr-hide-desktop{display:none!important}';
1880
+ /*
1881
+ * A per-document CSP, and the nonce is the point.
1882
+ *
1883
+ * The server strips <script> and every on* handler before this HTML is
1884
+ * sent, so no author JavaScript runs today. The residual risk is a
1885
+ * SANITISER BYPASS - mXSS in HTML sanitisers is a recurring class - and
1886
+ * because this iframe is `allow-scripts allow-same-origin`, injected script
1887
+ * would run at the HOST PAGE'S ORIGIN. That is XSS on a customer's site,
1888
+ * reachable from a compromised marketing account.
1889
+ *
1890
+ * `script-src 'nonce-...'` closes that: only the bootstrap below carries
1891
+ * the nonce, so anything that survives sanitisation still cannot execute.
1892
+ * Two independent controls instead of one, which matters when the first is
1893
+ * a parser fighting an adversary.
1894
+ *
1895
+ * The rest is least-privilege for content that is only ever a message:
1896
+ * default-src 'none' nothing is allowed unless named below
1897
+ * style-src unsafe-inline the sanitiser ALLOWS style="" attributes, so
1898
+ * this must stay permissive; styles cannot execute
1899
+ * img/media/font https: data: no http subresources - the mixed-content
1900
+ * control Android enforced and iOS could not
1901
+ * upgrade-insecure-requests an author's http image is upgraded rather
1902
+ * than simply broken, so it still has a chance to load
1903
+ * connect-src 'none' the bridge is a same-origin property call, not a
1904
+ * fetch, so nothing legitimate needs the network
1905
+ */
1906
+ const nonce = randomNonce();
1907
+ const csp = [
1908
+ "default-src 'none'",
1909
+ `script-src 'nonce-${nonce}'`,
1910
+ "style-src 'unsafe-inline'",
1911
+ 'img-src https: data:',
1912
+ 'media-src https: data:',
1913
+ 'font-src https: data:',
1914
+ "connect-src 'none'",
1915
+ "form-action 'none'",
1916
+ "base-uri 'none'",
1917
+ 'upgrade-insecure-requests',
1918
+ ].join('; ');
1919
+
1920
+ // The author's CSS goes inside <style>: a `</style>` in it would end the
1921
+ // sheet and inject markup (a meta refresh, a form) into the message
1922
+ // document. `</` is written `<\/` - the same characters to the CSS parser,
1923
+ // never an end tag to the HTML parser (rule 57).
1924
+ css = escapeStyleText(css);
1925
+ return `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${csp}"><meta name="viewport" content="width=device-width, initial-scale=1"><style>html,body{margin:0;padding:0;}img,video{max-width:100%;height:auto;}${bodySizing}${visRules}${css}</style></head><body>${html}<script nonce="${nonce}">${bootstrap}</script></body></html>`;
1926
+ }
1927
+
1928
+ /**
1929
+ * Rewrites author CSS so every rule applies only inside `scope`.
1930
+ *
1931
+ * Parsed with the BROWSER's own CSS parser rather than a regex: a hand-rolled
1932
+ * splitter gets strings, comments, nested @media and escaped braces wrong,
1933
+ * and every one of those mistakes is a rule that escapes onto the customer's
1934
+ * page. The probe sheet is attached with `media="not all"` so it never
1935
+ * applies while being parsed, then removed.
1936
+ *
1937
+ * Returns '' when nothing usable survives - and SAYS SO in the log, because
1938
+ * a marketer who wrote CSS and got no error would otherwise be left thinking
1939
+ * their code ran.
1940
+ */
1941
+ private scopeCss(css: string, scope: string): string {
1942
+ let probe: HTMLStyleElement | null = null;
1943
+ try {
1944
+ probe = document.createElement('style');
1945
+ probe.media = 'not all';
1946
+ probe.textContent = css;
1947
+ document.head.appendChild(probe);
1948
+ const sheet = probe.sheet;
1949
+ if (!sheet) {
1950
+ this.logger.warn('Custom CSS could not be parsed; ignoring it');
1951
+ return '';
1952
+ }
1953
+ const scoped = scopeCssRules(sheet.cssRules, scope);
1954
+ if (!scoped) {
1955
+ this.logger.warn('Custom CSS contained no rules that can be scoped; ignoring it');
1956
+ }
1957
+ return scoped;
1958
+ } catch (error) {
1959
+ this.logger.warn('Custom CSS could not be parsed; ignoring it', error);
1960
+ return '';
1961
+ } finally {
1962
+ probe?.remove();
1963
+ }
1964
+ }
1965
+
1966
+ /**
1967
+ * Dismiss a message
1968
+ */
1969
+ async dismiss(campaignId: string, action: string = 'dismiss'): Promise<void> {
1970
+ // A message still waiting out its delay is cancelled rather than shown
1971
+ // later: the user has already dealt with it.
1972
+ const pending = this.pendingDelays.get(campaignId);
1973
+ if (pending) {
1974
+ clearTimeout(pending);
1975
+ this.pendingDelays.delete(campaignId);
1976
+ }
1977
+
1978
+ const container = document.getElementById(`joryio-inapp-${campaignId}`);
1979
+ const backdrop = document.getElementById(`joryio-backdrop-${campaignId}`);
1980
+ const style = document.getElementById(`joryio-style-${campaignId}`);
1981
+ const customCss = document.getElementById(`joryio-css-${campaignId}`);
1982
+
1983
+ if (container) {
1984
+ container.remove(); // also removes the child content iframe
1985
+ }
1986
+ this.iframes.delete(campaignId);
1987
+ if (backdrop) {
1988
+ backdrop.remove();
1989
+ }
1990
+ if (style) {
1991
+ style.remove();
1992
+ }
1993
+ // Leaving this behind would restyle the NEXT message on the page, since the
1994
+ // author's rules are keyed by campaign id but the class names are shared.
1995
+ if (customCss) {
1996
+ customCss.remove();
1997
+ }
1998
+
1999
+ const message = this.displayedMessages.get(campaignId);
2000
+ if (message) {
2001
+ // `action` (close_button, backdrop, ...) goes to the onDismiss callback;
2002
+ // the wire carries the vocabulary value, which trackImpression sets.
2003
+ // The slot is released whatever happens to the report (sending blocked,
2004
+ // a failed request) - contract item 43.
2005
+ await this.trackImpression(campaignId, 'dismissed').catch(() => undefined);
2006
+
2007
+ this.callHost('onMessageDismiss', () => this.displayOptions.onDismiss?.(message, action));
2008
+
2009
+ this.displayedMessages.delete(campaignId);
2010
+ }
2011
+
2012
+ this.logger.info(`Dismissed campaign: ${campaignId}`);
2013
+ }
2014
+
2015
+ /**
2016
+ * Track conversion for a message
2017
+ */
2018
+ async trackConversion(campaignId: string, event: string): Promise<void> {
2019
+ await this.trackImpression(campaignId, 'converted', { conversionEvent: event });
2020
+
2021
+ const message = this.displayedMessages.get(campaignId);
2022
+ if (message) this.callHost('onConversion', () => this.displayOptions.onConversion?.(message, event));
2023
+ }
2024
+
2025
+ /**
2026
+ * Track impression
2027
+ */
2028
+ /** Post a message form's values with this SDK's identity; the answer is the server's verdict for the message runtime. */
2029
+ private async submitForm(campaignId: string, values: Record<string, unknown>): Promise<{ ok: boolean; message?: string; redirect?: string; errors?: string[] }> {
2030
+ const message = this.displayedMessages.get(campaignId);
2031
+ if (!this.runtime.canSend()) return { ok: false, message: 'This form cannot be sent right now.' };
2032
+ const controller = new AbortController();
2033
+ const timeoutId = setTimeout(() => controller.abort(), this.runtime.requestTimeoutMs);
2034
+ const subjectUser = this.identity.getUserId();
2035
+ try {
2036
+ const response = await fetch(`${this.apiEndpoint}/v1/in-app/form-submit`, {
2037
+ method: 'POST',
2038
+ headers: {
2039
+ 'Content-Type': 'application/json',
2040
+ 'X-SDK-Key': this.sdkKey,
2041
+ ...(this.auth?.getSdkAuthHeaders(subjectUser) || {}),
2042
+ },
2043
+ body: JSON.stringify({
2044
+ campaignId,
2045
+ variantId: (message?.campaign as any)?.variantId,
2046
+ userId: this.identity.getUserId() || undefined,
2047
+ anonymousId: this.identity.getAnonymousId(),
2048
+ values,
2049
+ }),
2050
+ signal: controller.signal,
2051
+ });
2052
+ await this.maybeHandleSdkAuthError(response, `${this.apiEndpoint}/v1/in-app/form-submit`, subjectUser);
2053
+ const json = await response.json().catch(() => null);
2054
+ if (!json || typeof json !== 'object') return { ok: false, message: 'Something went wrong. Please try again.' };
2055
+ return json;
2056
+ } catch {
2057
+ return { ok: false, message: 'Something went wrong. Please try again.' };
2058
+ } finally {
2059
+ clearTimeout(timeoutId);
2060
+ }
2061
+ }
2062
+
2063
+ private async trackImpression(
2064
+ campaignId: string,
2065
+ type: 'displayed' | 'clicked' | 'dismissed' | 'converted',
2066
+ extra?: Record<string, any>,
2067
+ ): Promise<void> {
2068
+ const campaign = this.campaigns.get(campaignId) || this.displayedMessages.get(campaignId)?.campaign;
2069
+ if (!campaign) return;
2070
+ // Opted out / consent pending / suspended: an impression is tracking too.
2071
+ if (!this.runtime.canSend()) return;
2072
+
2073
+ const sessionId = this.session.getSessionId();
2074
+
2075
+ const data: any = {
2076
+ campaignId,
2077
+ variantId: campaign.variantId,
2078
+ ...this.subject(),
2079
+ sessionId,
2080
+ deviceType: 'web',
2081
+ locale: navigator.language,
2082
+ userAgent: navigator.userAgent,
2083
+ // Echo the journey handle when the server queued this message from a
2084
+ // canvas node — without it the impression/click is recorded against the
2085
+ // campaign only and the journey node shows no stats.
2086
+ ...(campaign.pendingId ? { pendingId: campaign.pendingId } : {}),
2087
+ // Echo the delivery token — proof the server actually served this message.
2088
+ // The server prefers it over every identity field above, and uses it to
2089
+ // make each transition one-shot. Omitting it does not fail today, but the
2090
+ // impression is then unverifiable and will be rejected once the backend
2091
+ // requires it.
2092
+ ...(campaign.deliveryToken ? { deliveryToken: campaign.deliveryToken } : {}),
2093
+ ...extra,
2094
+ };
2095
+
2096
+ // Timestamps are ISO-8601 UTC (toISOString always ends in Z).
2097
+ //
2098
+ // `action` is ALWAYS the backend's vocabulary word
2099
+ // (common/events/in-app-action-vocabulary.ts). This used to send the
2100
+ // button id, 'close_button', 'backdrop', 'form_submit'... - none of which
2101
+ // the server knows, so each was answered `recorded: false,
2102
+ // reason: unknown_action` and web in-app clicks and dismissals never
2103
+ // reached reporting. (Fallbacks 'click' / 'dismiss' are synonyms the
2104
+ // vocabulary also accepts.)
2105
+ if (type === 'displayed') {
2106
+ data.displayedAt = new Date().toISOString();
2107
+ } else if (type === 'clicked') {
2108
+ data.clicked = true;
2109
+ data.clickedAt = new Date().toISOString();
2110
+ data.action = 'clicked';
2111
+ } else if (type === 'dismissed') {
2112
+ data.dismissedAt = new Date().toISOString();
2113
+ data.action = 'dismissed';
2114
+ } else if (type === 'converted') {
2115
+ data.converted = true;
2116
+ data.convertedAt = new Date().toISOString();
2117
+ }
2118
+
2119
+ const controller = new AbortController();
2120
+ const timeoutId = setTimeout(() => controller.abort(), this.runtime.requestTimeoutMs);
2121
+
2122
+ const subjectUser = typeof data.userId === 'string' && data.userId ? data.userId : null;
2123
+ try {
2124
+ const response = await fetch(`${this.apiEndpoint}/v1/in-app/track`, {
2125
+ method: 'POST',
2126
+ headers: {
2127
+ 'Content-Type': 'application/json',
2128
+ 'X-SDK-Key': this.sdkKey,
2129
+ ...(this.auth?.getSdkAuthHeaders(subjectUser) || {}),
2130
+ },
2131
+ body: JSON.stringify(data),
2132
+ signal: controller.signal,
2133
+ });
2134
+
2135
+ if (!response.ok) this.runtime.noteStatus?.(response.status, null);
2136
+ // Refresh the token on a 401 SDK-auth error so subsequent calls succeed.
2137
+ await this.maybeHandleSdkAuthError(response, `${this.apiEndpoint}/v1/in-app/track`, subjectUser);
2138
+
2139
+ this.logger.debug(`Tracked ${type} for campaign ${campaignId}`);
2140
+ } catch (error) {
2141
+ if ((error as any)?.name === 'AbortError') {
2142
+ this.logger.error(`Impression tracking timed out after ${this.runtime.requestTimeoutMs}ms for campaign ${campaignId}`);
2143
+ } else {
2144
+ this.logger.error(`Failed to track ${type}:`, error);
2145
+ }
2146
+ } finally {
2147
+ clearTimeout(timeoutId);
2148
+ }
2149
+ }
2150
+
2151
+ /**
2152
+ * Check frequency cap
2153
+ */
2154
+ private checkFrequencyCap(campaign: InAppCampaign): boolean {
2155
+ if (!campaign.frequencyCap) return true;
2156
+
2157
+ const { maxImpressions, timeWindow, minDelayBetweenImpressions } = campaign.frequencyCap;
2158
+ if (!maxImpressions || !timeWindow) return true;
2159
+ const timestamps = this.impressionTimestamps.get(campaign.id) || [];
2160
+
2161
+ // Calculate time window in milliseconds
2162
+ const now = Date.now();
2163
+ let windowStart = 0;
2164
+
2165
+ if (timeWindow === 'once' || timeWindow === 'lifetime') {
2166
+ // Check if ever displayed
2167
+ return timestamps.length === 0;
2168
+ } else if (timeWindow === 'session') {
2169
+ // Check only in current session
2170
+ windowStart = this.session.getSessionInfo().sessionStart;
2171
+ } else {
2172
+ // Parse time window (e.g. "1h", "7d", "30min", "3mo").
2173
+ //
2174
+ // LONGEST SUFFIX FIRST. Reading the last character alone was fine while
2175
+ // every unit was one letter, but "30min" and "3mo" both end in a letter
2176
+ // that means something else - `slice(-1)` reads "30min" as 30 MONTHS.
2177
+ // Shared vocabulary: backend common/in-app-frequency-window.ts.
2178
+ const units: Array<[string, number]> = [
2179
+ ['min', 60 * 1000],
2180
+ ['mo', 30 * 24 * 60 * 60 * 1000],
2181
+ ['h', 60 * 60 * 1000],
2182
+ ['d', 24 * 60 * 60 * 1000],
2183
+ ['w', 7 * 24 * 60 * 60 * 1000],
2184
+ ['m', 30 * 24 * 60 * 60 * 1000],
2185
+ ];
2186
+ const oneDay = 24 * 60 * 60 * 1000;
2187
+ let windowMs = oneDay;
2188
+ for (const [suffix, ms] of units) {
2189
+ if (!timeWindow.endsWith(suffix)) continue;
2190
+ const value = parseInt(timeWindow.slice(0, -suffix.length), 10);
2191
+ if (!Number.isNaN(value) && value > 0) windowMs = value * ms;
2192
+ break;
2193
+ }
2194
+ windowStart = now - windowMs;
2195
+ }
2196
+
2197
+ // Count impressions in window
2198
+ const recentTimestamps = timestamps.filter((t) => t >= windowStart);
2199
+
2200
+ if (recentTimestamps.length >= maxImpressions) {
2201
+ return false;
2202
+ }
2203
+
2204
+ // Check min delay between impressions
2205
+ if (minDelayBetweenImpressions && timestamps.length > 0) {
2206
+ const lastTimestamp = timestamps[timestamps.length - 1];
2207
+ const timeSinceLast = now - lastTimestamp;
2208
+ if (timeSinceLast < minDelayBetweenImpressions * 1000) {
2209
+ return false;
2210
+ }
2211
+ }
2212
+
2213
+ return true;
2214
+ }
2215
+
2216
+ /**
2217
+ * Start watching for attribute changes (for differential-sync)
2218
+ */
2219
+ private startAttributeWatching(): void {
2220
+ if (this.syncInterval) {
2221
+ clearInterval(this.syncInterval);
2222
+ this.syncInterval = undefined;
2223
+ }
2224
+ if (this.watchedAttributes.size === 0) return;
2225
+
2226
+ this.logger.debug(`Watching ${this.watchedAttributes.size} attributes for changes`);
2227
+
2228
+ // Store current attribute values
2229
+ const previousAttributes: Record<string, any> = {};
2230
+ this.watchedAttributes.forEach((attr) => {
2231
+ previousAttributes[attr] = this.identity.getAttribute(attr);
2232
+ });
2233
+
2234
+ // Check for changes periodically
2235
+ this.syncInterval = window.setInterval(() => {
2236
+ // Skip the diff + network re-sync while the tab is backgrounded — nothing
2237
+ // is changing user-visibly and we don't want to poll/fetch on hidden tabs.
2238
+ // Picks back up automatically when the tab becomes visible again.
2239
+ if (typeof document !== 'undefined' && document.hidden) {
2240
+ return;
2241
+ }
2242
+
2243
+ const changes: Record<string, any> = {};
2244
+ let hasChanges = false;
2245
+
2246
+ this.watchedAttributes.forEach((attr) => {
2247
+ const currentValue = this.identity.getAttribute(attr);
2248
+ if (currentValue !== previousAttributes[attr]) {
2249
+ changes[attr] = currentValue;
2250
+ previousAttributes[attr] = currentValue;
2251
+ hasChanges = true;
2252
+ }
2253
+ });
2254
+
2255
+ if (hasChanges) {
2256
+ // FAST PATH: a campaign already held whose attribute_threshold is now
2257
+ // satisfied displays immediately, with no round trip.
2258
+ this.checkAttributeThresholds(Object.keys(changes));
2259
+
2260
+ // SLOW PATH, and NOT redundant: a changed attribute can make the user
2261
+ // newly eligible for a campaign this device has never been sent - the
2262
+ // server pre-authorises, so only it can know that. The difference from
2263
+ // `event` triggers, where the campaign is already here by definition.
2264
+ // Not in session-start-only mode, which syncs at session start only.
2265
+ if (this.runtime.evaluationMode === 'session-start-only' || !this.runtime.canSend()) return;
2266
+ this.logger.info('Attribute changes detected, re-syncing campaigns');
2267
+ this.sync(changes).catch((error) => {
2268
+ this.logger.error('Failed to re-sync on attribute change:', error);
2269
+ });
2270
+ }
2271
+ }, 5000); // Check every 5 seconds
2272
+ }
2273
+
2274
+ /**
2275
+ * Display a held campaign whose `attribute_threshold` is now met.
2276
+ *
2277
+ * A trigger type the editor offers and the server sends, evaluated by no
2278
+ * SDK: "show when loyaltyPoints passes 1000" only ever appeared on a later
2279
+ * sync, if at all.
2280
+ *
2281
+ * Evaluated against the CURRENT attribute values, not the changed keys alone
2282
+ * - a threshold is about where the value landed, not that it moved.
2283
+ */
2284
+ private checkAttributeThresholds(changedKeys: string[]): void {
2285
+ if (!changedKeys.length) return;
2286
+ const attributes = this.identity.getAttributes() || {};
2287
+
2288
+ for (const campaign of this.campaigns.values()) {
2289
+ const hit = (campaign.triggers || []).some(
2290
+ (t) =>
2291
+ t.type === 'attribute_threshold' &&
2292
+ !!t.attribute &&
2293
+ changedKeys.includes(t.attribute) &&
2294
+ this.triggerFilterMatches(t, attributes),
2295
+ );
2296
+ if (hit) {
2297
+ this.displayCampaign(campaign).catch((error) =>
2298
+ this.logger.error('In-app display failed:', error));
2299
+ return;
2300
+ }
2301
+ }
2302
+ }
2303
+
2304
+ /**
2305
+ * Get all active campaigns
2306
+ */
2307
+ getCampaigns(): InAppCampaign[] {
2308
+ return Array.from(this.campaigns.values());
2309
+ }
2310
+
2311
+ /**
2312
+ * Get a specific campaign
2313
+ */
2314
+ getCampaign(campaignId: string): InAppCampaign | undefined {
2315
+ return this.campaigns.get(campaignId);
2316
+ }
2317
+
2318
+ /**
2319
+ * Clear all in-app messaging state (used during reset)
2320
+ */
2321
+ clearAll(): void {
2322
+ this.logger.info('Clearing in-app messaging state');
2323
+
2324
+ // Answers to requests made for the outgoing visitor are now stale.
2325
+ this.invalidate();
2326
+
2327
+ // Every timer: attribute poll, periodic sync, sync retry, display delays.
2328
+ // reset() used to leave a pending delayed message and the 5 s retry
2329
+ // running, so user A's message could pop up after user B logged in.
2330
+ this.stop();
2331
+
2332
+ // Dismiss all displayed messages
2333
+ for (const campaignId of this.displayedMessages.keys()) {
2334
+ this.dismiss(campaignId, 'clear').catch((error) =>
2335
+ this.logger.error('In-app dismiss failed:', error));
2336
+ }
2337
+
2338
+ // Clear caches. NOT the impression history: frequency caps are kept per
2339
+ // browser and survive a reload anyway, and clearing only the in-memory
2340
+ // copy meant the next display re-persisted that one campaign alone - the
2341
+ // stored caps of every other campaign were silently erased.
2342
+ this.campaigns.clear();
2343
+ this.displayedMessages.clear();
2344
+ this.watchedAttributes.clear();
2345
+ this.iframes.clear();
2346
+
2347
+ this.logger.info('In-app messaging state cleared');
2348
+ }
2349
+
2350
+ /** Erase the stored impression history (wipeData): every frequency cap starts again. */
2351
+ forgetImpressions(): void {
2352
+ this.impressionTimestamps.clear();
2353
+ try {
2354
+ this.storage?.remove(IMPRESSIONS_STORAGE_KEY);
2355
+ } catch {
2356
+ // best-effort
2357
+ }
2358
+ }
2359
+
2360
+
2361
+
2362
+
2363
+ /**
2364
+ * Add an entry to a Map, evicting the oldest entry (first key) if it exceeds maxSize.
2365
+ */
2366
+ private boundedMapSet<K, V>(map: Map<K, V>, key: K, value: V, maxSize: number): void {
2367
+ map.set(key, value);
2368
+ if (map.size > maxSize) {
2369
+ const oldestKey = map.keys().next().value;
2370
+ if (oldestKey !== undefined) {
2371
+ map.delete(oldestKey);
2372
+ }
2373
+ }
2374
+ }
2375
+
2376
+ /**
2377
+ * Add an entry to a Set, evicting the oldest entry (first value) if it exceeds maxSize.
2378
+ */
2379
+ private boundedSetAdd<V>(set: Set<V>, value: V, maxSize: number): void {
2380
+ set.add(value);
2381
+ if (set.size > maxSize) {
2382
+ const oldestValue = set.values().next().value;
2383
+ if (oldestValue !== undefined) {
2384
+ set.delete(oldestValue);
2385
+ }
2386
+ }
2387
+ }
2388
+
2389
+ /**
2390
+ * Destroy in-app messaging
2391
+ */
2392
+ destroy(): void {
2393
+ this.logger.info('Destroying in-app messaging');
2394
+
2395
+ // Every timer, including display delays and the sync retry, which used
2396
+ // to outlive destroy().
2397
+ this.stop();
2398
+
2399
+ // Dismiss all displayed messages
2400
+ for (const campaignId of this.displayedMessages.keys()) {
2401
+ this.dismiss(campaignId, 'destroy').catch((error) =>
2402
+ this.logger.error('In-app dismiss failed:', error));
2403
+ }
2404
+
2405
+ // Clear caches
2406
+ this.campaigns.clear();
2407
+ this.displayedMessages.clear();
2408
+ this.impressionTimestamps.clear();
2409
+ this.watchedAttributes.clear();
2410
+ this.iframes.clear();
2411
+
2412
+ // Tear down the host bridge so a later new JoryioSDK() rebinds a fresh one
2413
+ // (installHostBridge overwrites, but clearing here avoids a dangling closure
2414
+ // over this destroyed instance if no re-init happens).
2415
+ if (typeof window !== 'undefined') {
2416
+ delete (window as any)[HOST_BRIDGE_KEY];
2417
+ }
2418
+ }
2419
+ }