@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.
- package/CHANGELOG.md +5 -0
- package/LICENSE +54 -0
- package/README.md +497 -2
- package/dist/core/config.d.ts +101 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/device.d.ts +14 -0
- package/dist/core/device.d.ts.map +1 -0
- package/dist/core/ecommerce.d.ts +67 -0
- package/dist/core/ecommerce.d.ts.map +1 -0
- package/dist/core/identity.d.ts +36 -0
- package/dist/core/identity.d.ts.map +1 -0
- package/dist/core/inapp.d.ts +169 -0
- package/dist/core/inapp.d.ts.map +1 -0
- package/dist/core/network.d.ts +158 -0
- package/dist/core/network.d.ts.map +1 -0
- package/dist/core/outbox.d.ts +79 -0
- package/dist/core/outbox.d.ts.map +1 -0
- package/dist/core/pending-attributes.d.ts +43 -0
- package/dist/core/pending-attributes.d.ts.map +1 -0
- package/dist/core/push.d.ts +59 -0
- package/dist/core/push.d.ts.map +1 -0
- package/dist/core/queue.d.ts +107 -0
- package/dist/core/queue.d.ts.map +1 -0
- package/dist/core/sdk.d.ts +169 -0
- package/dist/core/sdk.d.ts.map +1 -0
- package/dist/core/session.d.ts +36 -0
- package/dist/core/session.d.ts.map +1 -0
- package/dist/core/storage.d.ts +17 -0
- package/dist/core/storage.d.ts.map +1 -0
- package/dist/core/user.d.ts +28 -0
- package/dist/core/user.d.ts.map +1 -0
- package/dist/core/utm.d.ts +39 -0
- package/dist/core/utm.d.ts.map +1 -0
- package/dist/core/values.d.ts +3 -0
- package/dist/core/values.d.ts.map +1 -0
- package/dist/core/write-retry.d.ts +66 -0
- package/dist/core/write-retry.d.ts.map +1 -0
- package/dist/index.d.mts +711 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/joryio-sw.js +6 -0
- package/dist/joryio.cjs.js +7265 -0
- package/dist/joryio.min.js +7 -0
- package/dist/joryio.min.js.map +1 -0
- package/dist/joryio.mjs +7254 -0
- package/dist/joryio.umd.js +7271 -0
- package/dist/utils/bucket-hash.d.ts +2 -0
- package/dist/utils/bucket-hash.d.ts.map +1 -0
- package/dist/utils/logger.d.ts +14 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/uuid.d.ts +3 -0
- package/dist/utils/uuid.d.ts.map +1 -0
- package/package.json +90 -4
- package/src/core/config.ts +362 -0
- package/src/core/device.ts +50 -0
- package/src/core/ecommerce.ts +482 -0
- package/src/core/identity.ts +251 -0
- package/src/core/inapp.ts +2419 -0
- package/src/core/network.ts +1261 -0
- package/src/core/outbox.ts +707 -0
- package/src/core/pending-attributes.ts +350 -0
- package/src/core/push.ts +667 -0
- package/src/core/queue.ts +894 -0
- package/src/core/sdk.ts +3108 -0
- package/src/core/session.ts +311 -0
- package/src/core/storage.ts +157 -0
- package/src/core/user.ts +203 -0
- package/src/core/utm.ts +304 -0
- package/src/core/values.ts +85 -0
- package/src/core/write-retry.ts +136 -0
- package/src/index.ts +305 -0
- package/src/snippet/loader.js +134 -0
- package/src/utils/bucket-hash.ts +44 -0
- package/src/utils/logger.ts +58 -0
- package/src/utils/uuid.ts +29 -0
- 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 & 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 = '×';
|
|
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 = '×';
|
|
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
|
+
}
|