Skip to content
Merged
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
90 changes: 89 additions & 1 deletion src/GleapBannerManager.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import Gleap, { GleapFrameManager } from './Gleap';
import Gleap, { GleapFrameManager, GleapSession, GleapStreamedEvent } from './Gleap';
import { isOutboundActionBlocked } from './GleapCaptureSettings';
import { bootstrapGleapFrame } from './GleapHelper';
import { originMatchesUrl } from './GleapMessageSecurity';
Expand All @@ -19,11 +19,21 @@ const clipsOverflow = (style) => {
*/
export const bodyClipsInlineBanner = (htmlStyle, bodyStyle) => clipsOverflow(htmlStyle) && clipsOverflow(bodyStyle);

// "Until dismissed" banners (server: outboundactions/untilDismissed.ts) come back on every page
// load until the person closes them. The close is reported as this streamed event.
export const closedBannerEventName = (outboundId) => `outbound-${outboundId}-closed`;

// Closed until-dismissed banners per contact, so a copy the server queued before it heard of the
// close is not shown again here.
const MAX_CLOSED_BANNERS = 50;

export default class GleapBannerManager {
bannerUrl = 'https://outboundmedia.gleap.io';
bannerContainer = null;
bannerData = null;
disabled = false;
// Engagements whose close was reported again during this page load.
closedReportedThisPage = new Set();

// GleapBannerManager singleton
static instance;
Expand Down Expand Up @@ -83,7 +93,9 @@ export default class GleapBannerManager {
}
}
if (data.name === 'banner-close') {
const closedBanner = this.bannerData;
this.removeBannerUI();
this.bannerWasClosed(closedBanner);
}
// While the widget is hidden for a capture (screenshot or recording), actions open nothing.
if (isOutboundActionBlocked(data.name)) {
Expand Down Expand Up @@ -211,6 +223,82 @@ export default class GleapBannerManager {
}

showBanner(bannerData) {
const outboundId = bannerData?.untilDismissed ? bannerData?.outbound : null;
if (outboundId) {
// Closed here already: the server still sends it, so it never heard of the close. Tell it
// again, once per page load.
if (this.loadClosedBanners().includes(outboundId)) {
if (!this.closedReportedThisPage.has(outboundId)) {
this.closedReportedThisPage.add(outboundId);
this.reportBannerClosed(outboundId);
}
return;
}

// Already on screen: every trigger (e.g. each page view) delivers it again.
if (this.bannerContainer && this.bannerData?.untilDismissed && this.bannerData?.outbound === outboundId) {
return;
}
}

this.injectBannerUI(bannerData);
}

/**
* The person closed the banner (its X or one of its buttons). An until-dismissed banner stays
* closed: remembered here and reported to the server.
*/
bannerWasClosed(bannerData) {
const outboundId = bannerData?.untilDismissed ? bannerData?.outbound : null;
if (!outboundId) {
return;
}

this.rememberClosedBanner(outboundId);
this.closedReportedThisPage.add(outboundId);
this.reportBannerClosed(outboundId);
}

reportBannerClosed(outboundId) {
try {
const streamedEvent = GleapStreamedEvent.getInstance();
streamedEvent.logEvent(closedBannerEventName(outboundId));
// Right away, not with the next 2.5 s tick: the page may be left at once.
streamedEvent.streamEvents();
} catch (exp) {}
}

closedBannersKey() {
try {
const gleapId = GleapSession.getInstance()?.session?.gleapId;
return gleapId ? `gleap-closed-banners-${gleapId}` : null;
} catch (exp) {
return null;
}
}

loadClosedBanners() {
try {
const key = this.closedBannersKey();
if (!key) {
return [];
}
const closedBanners = JSON.parse(localStorage.getItem(key));
return Array.isArray(closedBanners) ? closedBanners : [];
} catch (exp) {
return [];
}
}

rememberClosedBanner(outboundId) {
try {
const key = this.closedBannersKey();
if (!key) {
return;
}
const closedBanners = this.loadClosedBanners().filter((id) => id !== outboundId);
closedBanners.push(outboundId);
localStorage.setItem(key, JSON.stringify(closedBanners.slice(-MAX_CLOSED_BANNERS)));
} catch (exp) {}
}
}
111 changes: 110 additions & 1 deletion src/GleapBannerManager.test.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
/**
* @jest-environment jsdom
*/
import GleapBannerManager, { bodyClipsInlineBanner } from './GleapBannerManager';
import GleapBannerManager, { bodyClipsInlineBanner, closedBannerEventName } from './GleapBannerManager';

const mockUrlHandler = jest.fn();
const mockLogEvent = jest.fn();
const mockStreamEvents = jest.fn();
const mockSession = { session: { gleapId: 'gleap-1' } };
jest.mock('./Gleap', () => ({
__esModule: true,
default: {},
GleapFrameManager: { getInstance: jest.fn(() => ({ urlHandler: mockUrlHandler })) },
GleapSession: { getInstance: jest.fn(() => mockSession) },
GleapStreamedEvent: { getInstance: jest.fn(() => ({ logEvent: mockLogEvent, streamEvents: mockStreamEvents })) },
}));

jest.mock('./GleapHelper', () => ({
Expand Down Expand Up @@ -115,3 +120,107 @@ describe('GleapBannerManager message origin', () => {
expect(mockUrlHandler).not.toHaveBeenCalled();
});
});

describe('GleapBannerManager until dismissed', () => {
const manager = GleapBannerManager.getInstance();
const OUTBOUND = '6aeb2bad84e85e151afe22c1';
const banner = (extra = {}) => ({ format: 'floating', outbound: OUTBOUND, untilDismissed: true, ...extra });

const close = () => {
const frame = document.querySelector('.gleap-b-frame');
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({ type: 'BANNER', name: 'banner-close' }),
source: frame.contentWindow,
})
);
};

const frames = () => document.querySelectorAll('.gleap-b-frame').length;

beforeEach(() => {
localStorage.clear();
manager.closedReportedThisPage = new Set();
mockSession.session = { gleapId: 'gleap-1' };
mockLogEvent.mockClear();
mockStreamEvents.mockClear();
});

afterEach(() => {
manager.removeBannerUI();
manager.bannerData = null;
});

test('closing reports the close right away and remembers it', () => {
manager.showBanner(banner());
expect(frames()).toBe(1);

close();

expect(frames()).toBe(0);
expect(mockLogEvent).toHaveBeenCalledWith(closedBannerEventName(OUTBOUND));
expect(mockStreamEvents).toHaveBeenCalled();
expect(JSON.parse(localStorage.getItem('gleap-closed-banners-gleap-1'))).toEqual([OUTBOUND]);
});

test('a closed banner is not shown again, and the close is re-sent once per page load', () => {
manager.showBanner(banner());
close();
mockLogEvent.mockClear();
manager.closedReportedThisPage = new Set(); // next page load

manager.showBanner(banner());
manager.showBanner(banner());

expect(frames()).toBe(0);
expect(mockLogEvent).toHaveBeenCalledTimes(1);
expect(mockLogEvent).toHaveBeenCalledWith(closedBannerEventName(OUTBOUND));
});

test('the closed list belongs to the contact', () => {
manager.showBanner(banner());
close();

mockSession.session = { gleapId: 'gleap-2' };
manager.showBanner(banner());

expect(frames()).toBe(1);
});

test('a banner already on screen is not injected again', () => {
manager.showBanner(banner());
const frame = document.querySelector('.gleap-b-frame');

manager.showBanner(banner());

expect(frames()).toBe(1);
expect(document.querySelector('.gleap-b-frame')).toBe(frame);
});

test('other banners keep today\'s behaviour', () => {
manager.showBanner(banner({ untilDismissed: undefined }));
const frame = document.querySelector('.gleap-b-frame');
manager.showBanner(banner({ untilDismissed: undefined }));
expect(document.querySelector('.gleap-b-frame')).not.toBe(frame);

close();
expect(mockLogEvent).not.toHaveBeenCalled();
expect(localStorage.getItem('gleap-closed-banners-gleap-1')).toBeNull();
});

test('keeps at most 50 closed banners', () => {
const ids = Array.from({ length: 55 }, (_, i) => `id-${i}`);
ids.forEach((id) => manager.rememberClosedBanner(id));
const stored = JSON.parse(localStorage.getItem('gleap-closed-banners-gleap-1'));
expect(stored).toHaveLength(50);
expect(stored[49]).toBe('id-54');
});

test('no session yet: nothing stored, the banner still shows', () => {
mockSession.session = null;
manager.showBanner(banner());
close();
expect(localStorage.length).toBe(0);
expect(mockLogEvent).toHaveBeenCalledWith(closedBannerEventName(OUTBOUND));
});
});
Loading