From 7650cb84d0a1c2b78dbef9fdce4ea1649f95c719 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20B=C3=B6hler?= Date: Fri, 6 Nov 2026 11:42:14 +0100 Subject: [PATCH] Banners: report the close of "until dismissed" banners (#227730) An until-dismissed banner (action.untilDismissed) sends the streamed event outbound--closed when its X or a button closes it, flushed right away, and is remembered per contact in localStorage. A remembered banner is not shown again; the close is re-sent once per page load so a close that never reached the server heals. One already on screen is not injected again on the next trigger. Other banners behave as before. Co-Authored-By: Claude Opus 5.5 --- src/GleapBannerManager.js | 90 +++++++++++++++++++++++++- src/GleapBannerManager.test.js | 111 ++++++++++++++++++++++++++++++++- 2 files changed, 199 insertions(+), 2 deletions(-) diff --git a/src/GleapBannerManager.js b/src/GleapBannerManager.js index f2dc456e..c12785fe 100644 --- a/src/GleapBannerManager.js +++ b/src/GleapBannerManager.js @@ -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'; @@ -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; @@ -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)) { @@ -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) {} + } } diff --git a/src/GleapBannerManager.test.js b/src/GleapBannerManager.test.js index 6ddf5313..3c2f7320 100644 --- a/src/GleapBannerManager.test.js +++ b/src/GleapBannerManager.test.js @@ -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', () => ({ @@ -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)); + }); +});