Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build/browser/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/browser/index.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/cjs/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/cjs/index.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/esm/index.mjs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/esm/index.mjs.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion published/16.3.1/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion published/16.3.1/index.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion published/latest/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion published/latest/index.js.map

Large diffs are not rendered by default.

16 changes: 10 additions & 6 deletions src/Gleap.js
Original file line number Diff line number Diff line change
Expand Up @@ -1367,14 +1367,18 @@ class Gleap {
for (let i = 0; i < actions.length; i++) {
const action = actions[i];
if (action && action.actionType) {
// Notification bubbles get their page rules evaluated at render time
// (and re-evaluated on URL changes) by GleapNotificationManager. The
// Notification bubbles and banners get their page rules evaluated at
// render time (and re-evaluated on URL changes) by their managers. The
// server marks outbound actions as sent on delivery, so suppressing
// them here on an excluded page would consume the notification
// without the user ever seeing it. Banners, modals, surveys, tours
// and checklists opening the widget stay gated at arrival.
// them here on an excluded page would consume the outbound without
// the user ever seeing it — and an arrival-only check let banners
// shown on an allowed page follow users onto excluded ones in SPAs.
// Modals, surveys, tours and checklists opening the widget stay gated
// at arrival.
const pageRulesCheckedAtRenderTime =
action.actionType === 'notification' && action?.data?.checklist?.popupType !== 'widget';
(action.actionType === 'notification' &&
action?.data?.checklist?.popupType !== 'widget') ||
action.actionType === 'banner';

if (
!pageRulesCheckedAtRenderTime &&
Expand Down
59 changes: 58 additions & 1 deletion src/GleapBannerManager.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Gleap, { GleapFrameManager } from './Gleap';
import { bootstrapGleapFrame } from './GleapHelper';
import { checkPageRules } from './GleapPageFilter';

export default class GleapBannerManager {
bannerUrl = 'https://outboundmedia.gleap.io';
Expand Down Expand Up @@ -63,6 +64,9 @@ export default class GleapBannerManager {
}
}
if (data.name === 'banner-close') {
// User dismissal — drop the data so the page-rule re-evaluation
// (checkPageRulesForUrl) can't resurrect the banner on navigation.
this.bannerData = null;
this.removeBannerUI();
}
if (data.name === 'start-conversation') {
Expand Down Expand Up @@ -163,6 +167,59 @@ export default class GleapBannerManager {
}

showBanner(bannerData) {
this.injectBannerUI(bannerData);
// Banners keep their outbound pageRules/pageFilter, so visibility is
// decided at render time — not arrival (see performActions): the server
// marks outbound actions as sent on delivery, so an arrival-time check
// silently consumed banners that arrived on an excluded page — and
// banners rendered on an allowed page followed users onto excluded ones
// in SPAs.
this.bannerData = bannerData;
this.lastPageRulesUrl = null;

const currentUrl =
typeof window !== 'undefined' && window.location ? window.location.href : null;
if (this.bannerPassesPageRules(bannerData, currentUrl)) {
this.injectBannerUI(bannerData);
}
}

/**
* Whether the banner's page rules allow it on the given page.
*/
bannerPassesPageRules(bannerData, currentUrl) {
try {
if (!currentUrl || !bannerData) {
return true;
}
return checkPageRules(currentUrl, bannerData);
} catch (exp) {
return true;
}
}

/**
* Re-evaluates page rules when the URL changes (SPA navigations), so the
* banner hides on excluded pages and re-appears on allowed ones. Invoked
* ~1x/s by GleapStreamedEvent's page listener.
*/
checkPageRulesForUrl(currentUrl) {
if (!currentUrl || currentUrl === this.lastPageRulesUrl) {
return;
}
this.lastPageRulesUrl = currentUrl;

const bannerData = this.bannerData;
// Only page-ruled banners can change visibility with the URL.
if (!bannerData || !(bannerData.pageRules?.length > 0 || bannerData.pageFilter)) {
return;
}

const passes = this.bannerPassesPageRules(bannerData, currentUrl);
const shown = !!this.bannerContainer;
if (passes && !shown) {
this.injectBannerUI(bannerData);
} else if (!passes && shown) {
this.removeBannerUI();
}
}
}
105 changes: 105 additions & 0 deletions src/GleapBannerManager.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* @jest-environment jsdom
*/
import { bootstrapGleapFrame } from './GleapHelper';
import GleapBannerManager from './GleapBannerManager';

// Stub the barrel so the real GleapBannerManager loads without the full SDK.
jest.mock('./Gleap', () => ({
__esModule: true,
default: {},
GleapFrameManager: { getInstance: jest.fn(() => ({ urlHandler: jest.fn() })) },
}));

jest.mock('./GleapHelper', () => ({
bootstrapGleapFrame: jest.fn(),
}));

const HOME = 'https://communitise.com/';
const SUBPAGE = 'https://communitise.com/communities/123/feed';

const homeOnlyBanner = () => ({
format: 'inline',
pageRules: [{ pageFilter: HOME, pageFilterType: 'is' }],
pageFilter: HOME,
pageFilterType: 'is',
});

const bannerVisible = () => document.body.querySelector('.gleap-b') !== null;

beforeEach(() => {
document.body.innerHTML = '';
document.body.className = '';
GleapBannerManager.instance = null;
bootstrapGleapFrame.mockClear();
});

describe('banner page rules across SPA navigations', () => {
test('banner without page rules shows immediately and stays', () => {
const bm = GleapBannerManager.getInstance();
bm.showBanner({ format: 'inline' });
expect(bannerVisible()).toBe(true);

bm.checkPageRulesForUrl(SUBPAGE);
expect(bannerVisible()).toBe(true);
});

test('page-ruled banner hides when navigating to an excluded page and re-appears on an allowed one', () => {
const bm = GleapBannerManager.getInstance();
bm.showBanner(homeOnlyBanner());
// jsdom URL (http://localhost/) fails the "is" rule, so it starts pending.
expect(bannerVisible()).toBe(false);

bm.checkPageRulesForUrl(HOME);
expect(bannerVisible()).toBe(true);

bm.checkPageRulesForUrl(SUBPAGE);
expect(bannerVisible()).toBe(false);

bm.checkPageRulesForUrl(HOME);
expect(bannerVisible()).toBe(true);
});

test('banner arriving on an excluded page is kept pending, not consumed', () => {
const bm = GleapBannerManager.getInstance();
bm.showBanner(homeOnlyBanner());
expect(bannerVisible()).toBe(false);
expect(bm.bannerData).not.toBeNull();

bm.checkPageRulesForUrl(HOME);
expect(bannerVisible()).toBe(true);
});

test('same URL is only evaluated once per change', () => {
const bm = GleapBannerManager.getInstance();
bm.showBanner(homeOnlyBanner());
bm.checkPageRulesForUrl(HOME);
expect(bannerVisible()).toBe(true);

const injectSpy = jest.spyOn(bm, 'injectBannerUI');
bm.checkPageRulesForUrl(HOME);
bm.checkPageRulesForUrl(HOME);
expect(injectSpy).not.toHaveBeenCalled();
});

test('user dismissal is final — navigation does not resurrect the banner', () => {
const bm = GleapBannerManager.getInstance();
bm.showBanner(homeOnlyBanner());
bm.checkPageRulesForUrl(HOME);
expect(bannerVisible()).toBe(true);

// Simulate the banner iframe posting banner-close (user clicked X).
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({ type: 'BANNER', name: 'banner-close' }),
origin: 'https://outboundmedia.gleap.io',
})
);
expect(bannerVisible()).toBe(false);
expect(bm.bannerData).toBeNull();

bm.checkPageRulesForUrl(SUBPAGE);
bm.checkPageRulesForUrl(HOME);
expect(bannerVisible()).toBe(false);
});
});
12 changes: 8 additions & 4 deletions src/GleapStreamedEvent.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Gleap, {
GleapMetaDataManager,
GleapAiChatbarManager,
GleapNotificationManager,
GleapBannerManager,
GleapSession,
GleapAdminManager,
GleapEventManager,
Expand Down Expand Up @@ -260,13 +261,16 @@ export default class GleapStreamedEvent {
setInterval(function () {
self.logCurrentPage();

// Re-evaluate notification page rules on URL changes so bubbles hide
// on excluded pages and come back on allowed ones. Deliberately outside
// logCurrentPage: disablePageTracking only disables pageView streaming,
// not page rules.
// Re-evaluate notification and banner page rules on URL changes so they
// hide on excluded pages and come back on allowed ones. Deliberately
// outside logCurrentPage: disablePageTracking only disables pageView
// streaming, not page rules.
try {
GleapNotificationManager.getInstance().checkPageRulesForUrl(window.location.href);
} catch (exp) {}
try {
GleapBannerManager.getInstance().checkPageRulesForUrl(window.location.href);
} catch (exp) {}
}, 1000);
}

Expand Down