@joryio/web-sdk 1.3.1-beta.1 → 1.3.1-beta.2

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/dist/joryio.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Joryio Web SDK v1.3.1-beta.1
2
+ * Joryio Web SDK v1.3.1-beta.2
3
3
  * (c) 2026 Joryio. All rights reserved.
4
4
  * @license Commercial - see https://github.com/joryio/joryio-web-sdk/blob/main/LICENSE
5
5
  */
@@ -2261,6 +2261,21 @@ class UTMManager {
2261
2261
  function escapeStyleText(css) {
2262
2262
  return typeof css === 'string' ? css.replace(/<\//g, '<\\/') : '';
2263
2263
  }
2264
+ const IN_APP_DEFAULT_MAX_WIDTH = { modal: 600, slideup: 400 };
2265
+ const IN_APP_VIEWPORT_MARGIN = 16;
2266
+ function inAppMaxWidth(campaign) {
2267
+ var _a;
2268
+ if (campaign.type !== 'modal' && campaign.type !== 'slideup')
2269
+ return null;
2270
+ if (((_a = campaign.content) === null || _a === void 0 ? void 0 : _a.kind) !== 'html')
2271
+ return null;
2272
+ const mw = campaign.content.maxWidth;
2273
+ return typeof mw === 'number' && Number.isFinite(mw) && mw > 0 ? Math.floor(mw) : IN_APP_DEFAULT_MAX_WIDTH[campaign.type];
2274
+ }
2275
+ function inAppFrameWidth(maxWidth, viewportWidth, margin = IN_APP_VIEWPORT_MARGIN) {
2276
+ const available = Math.max(0, Math.floor(viewportWidth) - 2 * margin);
2277
+ return Math.max(0, Math.min(Math.floor(maxWidth), available));
2278
+ }
2264
2279
  const HOST_BRIDGE_KEY = '__joryioInApp';
2265
2280
  const IMPRESSIONS_STORAGE_KEY = 'inapp_impressions';
2266
2281
  const MAX_DISPLAYED_MESSAGES = 500;
@@ -2376,6 +2391,7 @@ class InAppMessagingManager {
2376
2391
  this.syncAgain = false;
2377
2392
  this.claimed = new Set();
2378
2393
  this.iframes = new Map();
2394
+ this.frameMaxWidths = new Map();
2379
2395
  this.allowHtmlJsInAppMessages = false;
2380
2396
  this.bridge = null;
2381
2397
  this.auth = null;
@@ -2495,16 +2511,13 @@ class InAppMessagingManager {
2495
2511
  this.trackImpression(campaignId, 'clicked').catch(() => { });
2496
2512
  return this.submitForm(campaignId, values);
2497
2513
  },
2498
- setSize: (campaignId, width, height) => {
2514
+ setSize: (campaignId, _width, height) => {
2499
2515
  var _a;
2500
2516
  const iframe = this.iframes.get(campaignId);
2501
2517
  if (!iframe || !Number.isFinite(height) || height <= 0)
2502
2518
  return;
2503
2519
  const type = (_a = this.displayedMessages.get(campaignId)) === null || _a === void 0 ? void 0 : _a.campaign.type;
2504
2520
  iframe.style.height = type === 'fullscreen' ? '100%' : `${Math.ceil(height)}px`;
2505
- if ((type === 'modal' || type === 'slideup') && Number.isFinite(width) && width > 0) {
2506
- iframe.style.width = `${Math.ceil(width)}px`;
2507
- }
2508
2521
  },
2509
2522
  };
2510
2523
  }
@@ -3202,6 +3215,13 @@ class InAppMessagingManager {
3202
3215
  if (campaign.type === 'banner' || campaign.type === 'fullscreen') {
3203
3216
  iframe.style.width = '100%';
3204
3217
  }
3218
+ const maxWidth = inAppMaxWidth(campaign);
3219
+ if (maxWidth !== null) {
3220
+ container.style.maxWidth = 'none';
3221
+ this.frameMaxWidths.set(campaign.id, maxWidth);
3222
+ iframe.style.width = `${inAppFrameWidth(maxWidth, this.hostViewportWidth())}px`;
3223
+ this.watchViewport();
3224
+ }
3205
3225
  iframe.srcdoc = this.buildSrcdoc(campaign.id, renderedHtml, ((_d = campaign.content) === null || _d === void 0 ? void 0 : _d.kind) === 'html' ? campaign.content.css : '', campaign.type);
3206
3226
  container.appendChild(iframe);
3207
3227
  this.iframes.set(campaign.id, iframe);
@@ -3255,13 +3275,53 @@ class InAppMessagingManager {
3255
3275
  this.callHost('onMessageDisplay', () => { var _a, _b; return (_b = (_a = this.displayOptions).onDisplay) === null || _b === void 0 ? void 0 : _b.call(_a, message); });
3256
3276
  this.logger.info(`Displayed campaign: ${campaign.name} (${campaign.id})`);
3257
3277
  }
3278
+ hostViewportWidth() {
3279
+ const doc = typeof document !== 'undefined' ? document.documentElement : null;
3280
+ const cw = doc && typeof doc.clientWidth === 'number' ? doc.clientWidth : 0;
3281
+ if (cw > 0)
3282
+ return cw;
3283
+ return typeof window !== 'undefined' && typeof window.innerWidth === 'number' ? window.innerWidth : 0;
3284
+ }
3285
+ sizeContentFrame(campaignId) {
3286
+ const iframe = this.iframes.get(campaignId);
3287
+ const maxWidth = this.frameMaxWidths.get(campaignId);
3288
+ if (!iframe || maxWidth === undefined)
3289
+ return;
3290
+ iframe.style.width = `${inAppFrameWidth(maxWidth, this.hostViewportWidth())}px`;
3291
+ }
3292
+ watchViewport() {
3293
+ if (this.viewportListener || typeof window === 'undefined' || typeof window.addEventListener !== 'function')
3294
+ return;
3295
+ this.viewportListener = () => {
3296
+ if (this.viewportTimer)
3297
+ clearTimeout(this.viewportTimer);
3298
+ this.viewportTimer = setTimeout(() => {
3299
+ this.viewportTimer = undefined;
3300
+ for (const id of this.frameMaxWidths.keys())
3301
+ this.sizeContentFrame(id);
3302
+ }, 100);
3303
+ };
3304
+ window.addEventListener('resize', this.viewportListener);
3305
+ }
3306
+ unwatchViewportIfIdle() {
3307
+ if (this.frameMaxWidths.size > 0 || !this.viewportListener)
3308
+ return;
3309
+ if (typeof window !== 'undefined' && typeof window.removeEventListener === 'function') {
3310
+ window.removeEventListener('resize', this.viewportListener);
3311
+ }
3312
+ this.viewportListener = undefined;
3313
+ if (this.viewportTimer) {
3314
+ clearTimeout(this.viewportTimer);
3315
+ this.viewportTimer = undefined;
3316
+ }
3317
+ }
3258
3318
  buildSrcdoc(campaignId, html, css, type) {
3259
3319
  const cid = JSON.stringify(campaignId);
3260
3320
  const key = JSON.stringify(HOST_BRIDGE_KEY);
3261
3321
  const contentSized = type === 'modal' || type === 'slideup';
3262
- const bodySizing = contentSized ? 'body{display:inline-block;max-width:100vw;}' : '';
3322
+ const bodySizing = contentSized ? 'body{display:flow-root;}' : '';
3263
3323
  const bootstrap = `(function(){
3264
- var CID=${cid},KEY=${key};
3324
+ var CID=${cid},KEY=${key},SIZED=${contentSized ? 'true' : 'false'};
3265
3325
  // Per-device visibility: tag the root with the REAL device class from the
3266
3326
  // HOST page viewport (the iframe is message-sized, so its own innerWidth is
3267
3327
  // useless here). The jr-vp-* → jr-hide-* rules in <style> then hide elements
@@ -3310,7 +3370,7 @@ class InAppMessagingManager {
3310
3370
  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}
3311
3371
  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});
3312
3372
  },true);
3313
- 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){}}
3373
+ function reportSize(){try{var b=document.body,de=document.documentElement;var w=Math.max(b?b.scrollWidth:0,de.scrollWidth);var h=SIZED&&b?Math.ceil(Math.max(b.scrollHeight,b.getBoundingClientRect?b.getBoundingClientRect().height:0)):Math.max(b?b.scrollHeight:0,de.scrollHeight);call('setSize',[w,h]);}catch(e){}}
3314
3374
  if(document.readyState!=='loading')reportSize();else document.addEventListener('DOMContentLoaded',reportSize);
3315
3375
  window.addEventListener('load',reportSize);
3316
3376
  try{if(window.ResizeObserver&&document.body){new ResizeObserver(reportSize).observe(document.body);}}catch(e){}
@@ -3372,6 +3432,8 @@ class InAppMessagingManager {
3372
3432
  container.remove();
3373
3433
  }
3374
3434
  this.iframes.delete(campaignId);
3435
+ this.frameMaxWidths.delete(campaignId);
3436
+ this.unwatchViewportIfIdle();
3375
3437
  if (backdrop) {
3376
3438
  backdrop.remove();
3377
3439
  }
@@ -3599,6 +3661,8 @@ class InAppMessagingManager {
3599
3661
  this.displayedMessages.clear();
3600
3662
  this.watchedAttributes.clear();
3601
3663
  this.iframes.clear();
3664
+ this.frameMaxWidths.clear();
3665
+ this.unwatchViewportIfIdle();
3602
3666
  this.logger.info('In-app messaging state cleared');
3603
3667
  }
3604
3668
  forgetImpressions() {
@@ -3639,6 +3703,8 @@ class InAppMessagingManager {
3639
3703
  this.impressionTimestamps.clear();
3640
3704
  this.watchedAttributes.clear();
3641
3705
  this.iframes.clear();
3706
+ this.frameMaxWidths.clear();
3707
+ this.unwatchViewportIfIdle();
3642
3708
  if (typeof window !== 'undefined') {
3643
3709
  delete window[HOST_BRIDGE_KEY];
3644
3710
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Joryio Web SDK v1.3.1-beta.1
2
+ * Joryio Web SDK v1.3.1-beta.2
3
3
  * (c) 2026 Joryio. All rights reserved.
4
4
  * @license Commercial - see https://github.com/joryio/joryio-web-sdk/blob/main/LICENSE
5
5
  */
@@ -2267,6 +2267,21 @@
2267
2267
  function escapeStyleText(css) {
2268
2268
  return typeof css === 'string' ? css.replace(/<\//g, '<\\/') : '';
2269
2269
  }
2270
+ const IN_APP_DEFAULT_MAX_WIDTH = { modal: 600, slideup: 400 };
2271
+ const IN_APP_VIEWPORT_MARGIN = 16;
2272
+ function inAppMaxWidth(campaign) {
2273
+ var _a;
2274
+ if (campaign.type !== 'modal' && campaign.type !== 'slideup')
2275
+ return null;
2276
+ if (((_a = campaign.content) === null || _a === void 0 ? void 0 : _a.kind) !== 'html')
2277
+ return null;
2278
+ const mw = campaign.content.maxWidth;
2279
+ return typeof mw === 'number' && Number.isFinite(mw) && mw > 0 ? Math.floor(mw) : IN_APP_DEFAULT_MAX_WIDTH[campaign.type];
2280
+ }
2281
+ function inAppFrameWidth(maxWidth, viewportWidth, margin = IN_APP_VIEWPORT_MARGIN) {
2282
+ const available = Math.max(0, Math.floor(viewportWidth) - 2 * margin);
2283
+ return Math.max(0, Math.min(Math.floor(maxWidth), available));
2284
+ }
2270
2285
  const HOST_BRIDGE_KEY = '__joryioInApp';
2271
2286
  const IMPRESSIONS_STORAGE_KEY = 'inapp_impressions';
2272
2287
  const MAX_DISPLAYED_MESSAGES = 500;
@@ -2382,6 +2397,7 @@
2382
2397
  this.syncAgain = false;
2383
2398
  this.claimed = new Set();
2384
2399
  this.iframes = new Map();
2400
+ this.frameMaxWidths = new Map();
2385
2401
  this.allowHtmlJsInAppMessages = false;
2386
2402
  this.bridge = null;
2387
2403
  this.auth = null;
@@ -2501,16 +2517,13 @@
2501
2517
  this.trackImpression(campaignId, 'clicked').catch(() => { });
2502
2518
  return this.submitForm(campaignId, values);
2503
2519
  },
2504
- setSize: (campaignId, width, height) => {
2520
+ setSize: (campaignId, _width, height) => {
2505
2521
  var _a;
2506
2522
  const iframe = this.iframes.get(campaignId);
2507
2523
  if (!iframe || !Number.isFinite(height) || height <= 0)
2508
2524
  return;
2509
2525
  const type = (_a = this.displayedMessages.get(campaignId)) === null || _a === void 0 ? void 0 : _a.campaign.type;
2510
2526
  iframe.style.height = type === 'fullscreen' ? '100%' : `${Math.ceil(height)}px`;
2511
- if ((type === 'modal' || type === 'slideup') && Number.isFinite(width) && width > 0) {
2512
- iframe.style.width = `${Math.ceil(width)}px`;
2513
- }
2514
2527
  },
2515
2528
  };
2516
2529
  }
@@ -3208,6 +3221,13 @@
3208
3221
  if (campaign.type === 'banner' || campaign.type === 'fullscreen') {
3209
3222
  iframe.style.width = '100%';
3210
3223
  }
3224
+ const maxWidth = inAppMaxWidth(campaign);
3225
+ if (maxWidth !== null) {
3226
+ container.style.maxWidth = 'none';
3227
+ this.frameMaxWidths.set(campaign.id, maxWidth);
3228
+ iframe.style.width = `${inAppFrameWidth(maxWidth, this.hostViewportWidth())}px`;
3229
+ this.watchViewport();
3230
+ }
3211
3231
  iframe.srcdoc = this.buildSrcdoc(campaign.id, renderedHtml, ((_d = campaign.content) === null || _d === void 0 ? void 0 : _d.kind) === 'html' ? campaign.content.css : '', campaign.type);
3212
3232
  container.appendChild(iframe);
3213
3233
  this.iframes.set(campaign.id, iframe);
@@ -3261,13 +3281,53 @@
3261
3281
  this.callHost('onMessageDisplay', () => { var _a, _b; return (_b = (_a = this.displayOptions).onDisplay) === null || _b === void 0 ? void 0 : _b.call(_a, message); });
3262
3282
  this.logger.info(`Displayed campaign: ${campaign.name} (${campaign.id})`);
3263
3283
  }
3284
+ hostViewportWidth() {
3285
+ const doc = typeof document !== 'undefined' ? document.documentElement : null;
3286
+ const cw = doc && typeof doc.clientWidth === 'number' ? doc.clientWidth : 0;
3287
+ if (cw > 0)
3288
+ return cw;
3289
+ return typeof window !== 'undefined' && typeof window.innerWidth === 'number' ? window.innerWidth : 0;
3290
+ }
3291
+ sizeContentFrame(campaignId) {
3292
+ const iframe = this.iframes.get(campaignId);
3293
+ const maxWidth = this.frameMaxWidths.get(campaignId);
3294
+ if (!iframe || maxWidth === undefined)
3295
+ return;
3296
+ iframe.style.width = `${inAppFrameWidth(maxWidth, this.hostViewportWidth())}px`;
3297
+ }
3298
+ watchViewport() {
3299
+ if (this.viewportListener || typeof window === 'undefined' || typeof window.addEventListener !== 'function')
3300
+ return;
3301
+ this.viewportListener = () => {
3302
+ if (this.viewportTimer)
3303
+ clearTimeout(this.viewportTimer);
3304
+ this.viewportTimer = setTimeout(() => {
3305
+ this.viewportTimer = undefined;
3306
+ for (const id of this.frameMaxWidths.keys())
3307
+ this.sizeContentFrame(id);
3308
+ }, 100);
3309
+ };
3310
+ window.addEventListener('resize', this.viewportListener);
3311
+ }
3312
+ unwatchViewportIfIdle() {
3313
+ if (this.frameMaxWidths.size > 0 || !this.viewportListener)
3314
+ return;
3315
+ if (typeof window !== 'undefined' && typeof window.removeEventListener === 'function') {
3316
+ window.removeEventListener('resize', this.viewportListener);
3317
+ }
3318
+ this.viewportListener = undefined;
3319
+ if (this.viewportTimer) {
3320
+ clearTimeout(this.viewportTimer);
3321
+ this.viewportTimer = undefined;
3322
+ }
3323
+ }
3264
3324
  buildSrcdoc(campaignId, html, css, type) {
3265
3325
  const cid = JSON.stringify(campaignId);
3266
3326
  const key = JSON.stringify(HOST_BRIDGE_KEY);
3267
3327
  const contentSized = type === 'modal' || type === 'slideup';
3268
- const bodySizing = contentSized ? 'body{display:inline-block;max-width:100vw;}' : '';
3328
+ const bodySizing = contentSized ? 'body{display:flow-root;}' : '';
3269
3329
  const bootstrap = `(function(){
3270
- var CID=${cid},KEY=${key};
3330
+ var CID=${cid},KEY=${key},SIZED=${contentSized ? 'true' : 'false'};
3271
3331
  // Per-device visibility: tag the root with the REAL device class from the
3272
3332
  // HOST page viewport (the iframe is message-sized, so its own innerWidth is
3273
3333
  // useless here). The jr-vp-* → jr-hide-* rules in <style> then hide elements
@@ -3316,7 +3376,7 @@
3316
3376
  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}
3317
3377
  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});
3318
3378
  },true);
3319
- 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){}}
3379
+ function reportSize(){try{var b=document.body,de=document.documentElement;var w=Math.max(b?b.scrollWidth:0,de.scrollWidth);var h=SIZED&&b?Math.ceil(Math.max(b.scrollHeight,b.getBoundingClientRect?b.getBoundingClientRect().height:0)):Math.max(b?b.scrollHeight:0,de.scrollHeight);call('setSize',[w,h]);}catch(e){}}
3320
3380
  if(document.readyState!=='loading')reportSize();else document.addEventListener('DOMContentLoaded',reportSize);
3321
3381
  window.addEventListener('load',reportSize);
3322
3382
  try{if(window.ResizeObserver&&document.body){new ResizeObserver(reportSize).observe(document.body);}}catch(e){}
@@ -3378,6 +3438,8 @@
3378
3438
  container.remove();
3379
3439
  }
3380
3440
  this.iframes.delete(campaignId);
3441
+ this.frameMaxWidths.delete(campaignId);
3442
+ this.unwatchViewportIfIdle();
3381
3443
  if (backdrop) {
3382
3444
  backdrop.remove();
3383
3445
  }
@@ -3605,6 +3667,8 @@
3605
3667
  this.displayedMessages.clear();
3606
3668
  this.watchedAttributes.clear();
3607
3669
  this.iframes.clear();
3670
+ this.frameMaxWidths.clear();
3671
+ this.unwatchViewportIfIdle();
3608
3672
  this.logger.info('In-app messaging state cleared');
3609
3673
  }
3610
3674
  forgetImpressions() {
@@ -3645,6 +3709,8 @@
3645
3709
  this.impressionTimestamps.clear();
3646
3710
  this.watchedAttributes.clear();
3647
3711
  this.iframes.clear();
3712
+ this.frameMaxWidths.clear();
3713
+ this.unwatchViewportIfIdle();
3648
3714
  if (typeof window !== 'undefined') {
3649
3715
  delete window[HOST_BRIDGE_KEY];
3650
3716
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joryio/web-sdk",
3
- "version": "1.3.1-beta.1",
3
+ "version": "1.3.1-beta.2",
4
4
  "description": "Joryio Web Tracking SDK - Lightweight analytics and event tracking",
5
5
  "main": "dist/joryio.cjs.js",
6
6
  "module": "dist/joryio.mjs",
package/src/core/inapp.ts CHANGED
@@ -96,7 +96,17 @@ export interface InAppCampaign {
96
96
  * verbatim on web. Server-rendered HTML is now the only thing sent.
97
97
  */
98
98
  content?:
99
- | { kind: 'html'; html: string; css: string; assets?: unknown[] }
99
+ | {
100
+ kind: 'html';
101
+ html: string;
102
+ css: string;
103
+ assets?: unknown[];
104
+ /**
105
+ * Widest the message may be, in CSS px. Sent for `modal` and `slideup`,
106
+ * already resolved by the server; see inAppMaxWidth for the fallback.
107
+ */
108
+ maxWidth?: number;
109
+ }
100
110
  | {
101
111
  kind: 'native';
102
112
  title?: string;
@@ -202,6 +212,39 @@ export function escapeStyleText(css: string): string {
202
212
  return typeof css === 'string' ? css.replace(/<\//g, '<\\/') : '';
203
213
  }
204
214
 
215
+ /**
216
+ * The width a content-sized HTML message gets when its payload carries no
217
+ * `maxWidth` (a server that predates the field). The same two numbers as the
218
+ * backend's IN_APP_DEFAULT_MAX_WIDTH and every other SDK; the server normally
219
+ * sends the resolved value, so this is only ever a fallback.
220
+ */
221
+ export const IN_APP_DEFAULT_MAX_WIDTH = { modal: 600, slideup: 400 } as const;
222
+
223
+ /** Space kept between a content-sized message and each side of the viewport, in px. */
224
+ export const IN_APP_VIEWPORT_MARGIN = 16;
225
+
226
+ /**
227
+ * The maxWidth for this campaign, or null when its type is not content-sized
228
+ * (banner and fullscreen span the page; custom is the host's to place) or its
229
+ * content is native (the card has its own width).
230
+ */
231
+ export function inAppMaxWidth(campaign: Pick<InAppCampaign, 'type' | 'content'>): number | null {
232
+ if (campaign.type !== 'modal' && campaign.type !== 'slideup') return null;
233
+ if (campaign.content?.kind !== 'html') return null;
234
+ const mw = campaign.content.maxWidth;
235
+ return typeof mw === 'number' && Number.isFinite(mw) && mw > 0 ? Math.floor(mw) : IN_APP_DEFAULT_MAX_WIDTH[campaign.type];
236
+ }
237
+
238
+ /**
239
+ * The width the message frame is given: min(maxWidth, viewport - margin on
240
+ * each side). Set BEFORE the content lays out, so the author's media queries
241
+ * see the width the message really has; only the height follows the content.
242
+ */
243
+ export function inAppFrameWidth(maxWidth: number, viewportWidth: number, margin: number = IN_APP_VIEWPORT_MARGIN): number {
244
+ const available = Math.max(0, Math.floor(viewportWidth) - 2 * margin);
245
+ return Math.max(0, Math.min(Math.floor(maxWidth), available));
246
+ }
247
+
205
248
  // Host-window key the in-app iframes call into (same-origin). Internal.
206
249
  const HOST_BRIDGE_KEY = '__joryioInApp';
207
250
 
@@ -379,6 +422,14 @@ export class InAppMessagingManager {
379
422
  private claimed = new Set<string>();
380
423
  // Per-campaign content iframe (so we can auto-size + clean it up).
381
424
  private iframes: Map<string, HTMLIFrameElement> = new Map();
425
+ /**
426
+ * maxWidth of each displayed content-sized (modal / slideup) HTML message.
427
+ * Their frame width is derived from it and the host viewport, and re-derived
428
+ * when the viewport changes - see sizeContentFrame.
429
+ */
430
+ private frameMaxWidths: Map<string, number> = new Map();
431
+ private viewportListener?: () => void;
432
+ private viewportTimer?: ReturnType<typeof setTimeout>;
382
433
 
383
434
  /**
384
435
  * Whether HTML in-app messages are displayed. Off by default. The markup
@@ -582,10 +633,15 @@ export class InAppMessagingManager {
582
633
  this.trackImpression(campaignId, 'clicked').catch(() => {});
583
634
  return this.submitForm(campaignId, values);
584
635
  },
585
- // Size the content iframe from the message's reported dimensions. For
586
- // content-sized types (modal/slideup) both width + height track the content;
587
- // banner/fullscreen stay full-width (height tracks content for banner).
588
- setSize: (campaignId: string, width: number, height: number) => {
636
+ // Size the content iframe from the message's reported dimensions. Only
637
+ // the HEIGHT follows the content. The width is the host's decision:
638
+ // modal/slideup get min(maxWidth, viewport - margin) before the content
639
+ // lays out (sizeContentFrame), banner/fullscreen are 100%. Following the
640
+ // reported width is what made responsive messages impossible - the frame
641
+ // started narrow, the author's media queries fired, the content measured
642
+ // narrow, and the frame stayed narrow. `width` stays in the signature so
643
+ // the bridge every message calls is unchanged.
644
+ setSize: (campaignId: string, _width: number, height: number) => {
589
645
  // Guard on the iframe (the thing being sized), set synchronously in
590
646
  // display() — not displayedMessages, so a setSize on iframe-load is never
591
647
  // rejected by a timing gap.
@@ -593,9 +649,6 @@ export class InAppMessagingManager {
593
649
  if (!iframe || !Number.isFinite(height) || height <= 0) return;
594
650
  const type = this.displayedMessages.get(campaignId)?.campaign.type;
595
651
  iframe.style.height = type === 'fullscreen' ? '100%' : `${Math.ceil(height)}px`;
596
- if ((type === 'modal' || type === 'slideup') && Number.isFinite(width) && width > 0) {
597
- iframe.style.width = `${Math.ceil(width)}px`;
598
- }
599
652
  },
600
653
  };
601
654
  }
@@ -1696,12 +1749,22 @@ export class InAppMessagingManager {
1696
1749
  });
1697
1750
  iframe.setAttribute('title', campaign.name || 'Message');
1698
1751
  // Start at 0 height (avoids the ~150px default-iframe flash); the bootstrap
1699
- // reports real size on load via the bridge's setSize. Banner/fullscreen fill
1700
- // width; modal/slideup get their content width from setSize.
1752
+ // reports real height on load via the bridge's setSize. Banner/fullscreen
1753
+ // fill the width; modal/slideup get min(maxWidth, viewport - margin) NOW,
1754
+ // before the document loads, so its media queries see the real width.
1701
1755
  iframe.style.height = '0';
1702
1756
  if (campaign.type === 'banner' || campaign.type === 'fullscreen') {
1703
1757
  iframe.style.width = '100%';
1704
1758
  }
1759
+ const maxWidth = inAppMaxWidth(campaign);
1760
+ if (maxWidth !== null) {
1761
+ // The frame width already keeps the margin, so the container's own
1762
+ // caps (90% for modal, 400px for slide-up) would only clip it.
1763
+ container.style.maxWidth = 'none';
1764
+ this.frameMaxWidths.set(campaign.id, maxWidth);
1765
+ iframe.style.width = `${inAppFrameWidth(maxWidth, this.hostViewportWidth())}px`;
1766
+ this.watchViewport();
1767
+ }
1705
1768
  iframe.srcdoc = this.buildSrcdoc(
1706
1769
  campaign.id,
1707
1770
  renderedHtml,
@@ -1796,6 +1859,51 @@ export class InAppMessagingManager {
1796
1859
  this.logger.info(`Displayed campaign: ${campaign.name} (${campaign.id})`);
1797
1860
  }
1798
1861
 
1862
+ /** The host page's layout width: without the scrollbar where the browser says so. */
1863
+ private hostViewportWidth(): number {
1864
+ const doc = typeof document !== 'undefined' ? document.documentElement : null;
1865
+ const cw = doc && typeof doc.clientWidth === 'number' ? doc.clientWidth : 0;
1866
+ if (cw > 0) return cw;
1867
+ return typeof window !== 'undefined' && typeof window.innerWidth === 'number' ? window.innerWidth : 0;
1868
+ }
1869
+
1870
+ /** Re-derive a content-sized frame's width from its maxWidth and the viewport. */
1871
+ private sizeContentFrame(campaignId: string): void {
1872
+ const iframe = this.iframes.get(campaignId);
1873
+ const maxWidth = this.frameMaxWidths.get(campaignId);
1874
+ if (!iframe || maxWidth === undefined) return;
1875
+ iframe.style.width = `${inAppFrameWidth(maxWidth, this.hostViewportWidth())}px`;
1876
+ }
1877
+
1878
+ /**
1879
+ * Follow the viewport while a content-sized message is open: rotate a phone
1880
+ * or narrow a window and the message is re-fitted (debounced). The content's
1881
+ * ResizeObserver then reports the height at the new width.
1882
+ */
1883
+ private watchViewport(): void {
1884
+ if (this.viewportListener || typeof window === 'undefined' || typeof window.addEventListener !== 'function') return;
1885
+ this.viewportListener = () => {
1886
+ if (this.viewportTimer) clearTimeout(this.viewportTimer);
1887
+ this.viewportTimer = setTimeout(() => {
1888
+ this.viewportTimer = undefined;
1889
+ for (const id of this.frameMaxWidths.keys()) this.sizeContentFrame(id);
1890
+ }, 100);
1891
+ };
1892
+ window.addEventListener('resize', this.viewportListener);
1893
+ }
1894
+
1895
+ private unwatchViewportIfIdle(): void {
1896
+ if (this.frameMaxWidths.size > 0 || !this.viewportListener) return;
1897
+ if (typeof window !== 'undefined' && typeof window.removeEventListener === 'function') {
1898
+ window.removeEventListener('resize', this.viewportListener);
1899
+ }
1900
+ this.viewportListener = undefined;
1901
+ if (this.viewportTimer) {
1902
+ clearTimeout(this.viewportTimer);
1903
+ this.viewportTimer = undefined;
1904
+ }
1905
+ }
1906
+
1799
1907
  /**
1800
1908
  * Build the iframe srcdoc: the message HTML + its CSS + a bootstrap that
1801
1909
  * (a) exposes `window.joryioBridge` (proxying same-origin to the host bridge),
@@ -1821,13 +1929,19 @@ export class InAppMessagingManager {
1821
1929
  ): string {
1822
1930
  const cid = JSON.stringify(campaignId);
1823
1931
  const key = JSON.stringify(HOST_BRIDGE_KEY);
1824
- // Content-sized types shrink the body to its content so scrollWidth reflects
1825
- // the authored width (the host sets the iframe to it). Full-bleed types keep
1826
- // body at full width (the host forces the iframe to 100%, ignoring width).
1932
+ // Content-sized types: the HOST picks the width (min(maxWidth, viewport -
1933
+ // margin)) and only the height follows the content. The body used to be
1934
+ // inline-block so its width could be measured and the frame set to it -
1935
+ // which let the frame start narrow and stay narrow. Now the body is a
1936
+ // normal block at the frame's width; flow-root keeps the first and last
1937
+ // child's margins inside it, so its height is the content's height.
1938
+ // That height is measured from the BODY: the root's scrollHeight is never
1939
+ // less than the frame itself, so a message that got shorter (wider
1940
+ // viewport, a form message replaced by a line) could never shrink back.
1827
1941
  const contentSized = type === 'modal' || type === 'slideup';
1828
- const bodySizing = contentSized ? 'body{display:inline-block;max-width:100vw;}' : '';
1942
+ const bodySizing = contentSized ? 'body{display:flow-root;}' : '';
1829
1943
  const bootstrap = `(function(){
1830
- var CID=${cid},KEY=${key};
1944
+ var CID=${cid},KEY=${key},SIZED=${contentSized ? 'true' : 'false'};
1831
1945
  // Per-device visibility: tag the root with the REAL device class from the
1832
1946
  // HOST page viewport (the iframe is message-sized, so its own innerWidth is
1833
1947
  // useless here). The jr-vp-* → jr-hide-* rules in <style> then hide elements
@@ -1876,7 +1990,7 @@ export class InAppMessagingManager {
1876
1990
  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}
1877
1991
  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});
1878
1992
  },true);
1879
- 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){}}
1993
+ function reportSize(){try{var b=document.body,de=document.documentElement;var w=Math.max(b?b.scrollWidth:0,de.scrollWidth);var h=SIZED&&b?Math.ceil(Math.max(b.scrollHeight,b.getBoundingClientRect?b.getBoundingClientRect().height:0)):Math.max(b?b.scrollHeight:0,de.scrollHeight);call('setSize',[w,h]);}catch(e){}}
1880
1994
  if(document.readyState!=='loading')reportSize();else document.addEventListener('DOMContentLoaded',reportSize);
1881
1995
  window.addEventListener('load',reportSize);
1882
1996
  try{if(window.ResizeObserver&&document.body){new ResizeObserver(reportSize).observe(document.body);}}catch(e){}
@@ -1991,6 +2105,8 @@ export class InAppMessagingManager {
1991
2105
  container.remove(); // also removes the child content iframe
1992
2106
  }
1993
2107
  this.iframes.delete(campaignId);
2108
+ this.frameMaxWidths.delete(campaignId);
2109
+ this.unwatchViewportIfIdle();
1994
2110
  if (backdrop) {
1995
2111
  backdrop.remove();
1996
2112
  }
@@ -2350,6 +2466,8 @@ export class InAppMessagingManager {
2350
2466
  this.displayedMessages.clear();
2351
2467
  this.watchedAttributes.clear();
2352
2468
  this.iframes.clear();
2469
+ this.frameMaxWidths.clear();
2470
+ this.unwatchViewportIfIdle();
2353
2471
 
2354
2472
  this.logger.info('In-app messaging state cleared');
2355
2473
  }
@@ -2415,6 +2533,8 @@ export class InAppMessagingManager {
2415
2533
  this.impressionTimestamps.clear();
2416
2534
  this.watchedAttributes.clear();
2417
2535
  this.iframes.clear();
2536
+ this.frameMaxWidths.clear();
2537
+ this.unwatchViewportIfIdle();
2418
2538
 
2419
2539
  // Tear down the host bridge so a later new JoryioSDK() rebinds a fresh one
2420
2540
  // (installHostBridge overwrites, but clearing here avoids a dangling closure