diff --git a/src/banner/banner-manager.mjs b/src/banner/banner-manager.mjs index 8632167..b498094 100644 --- a/src/banner/banner-manager.mjs +++ b/src/banner/banner-manager.mjs @@ -138,4 +138,14 @@ export class BannerManager { return null; } + + getBannerByPosition(positionCode) { + for (let banner of this.#banners) { + if (banner.position === positionCode) { + return banner; + } + } + + return null; + } } diff --git a/src/banner/closing/closed-banner-store.mjs b/src/banner/closing/closed-banner-store.mjs index 026c3ec..8c6ef43 100644 --- a/src/banner/closing/closed-banner-store.mjs +++ b/src/banner/closing/closed-banner-store.mjs @@ -1,3 +1,5 @@ +import { EntryKey } from './closing-entry.mjs'; + export class ClosedBannerStore { #storage; #key; @@ -40,46 +42,100 @@ export class ClosedBannerStore { return; } - const currentItems = this.#loadedItems || []; - const newItems = null !== event.newValue && '' !== event.newValue ? event.newValue.split(',') : []; - const diff = newItems.filter(id => !currentItems.includes(id)); + const currentItems = this.#loadedItems || {}; + const newItems = null !== event.newValue && '' !== event.newValue ? JSON.parse(event.newValue) : {}; + + const currentKeys = Object.keys(currentItems); + const diffKeys = Object.keys(newItems) + .filter(key => !currentKeys.includes(key)) + .map(key => EntryKey.tryParse(key)) + .filter(key => null !== key); this.#loadedItems = newItems; - if (0 < diff.length) { - onExternalChange(diff); + if (0 < diffKeys.length) { + onExternalChange(diffKeys); } }); } } - persist(bannerId, closed) { + close(entries) { const items = this.#getItems(); - const index = items.indexOf(bannerId); + const now = this.#getNow(); let changed = false; - if (closed && -1 === index) { - items.push(bannerId); - changed = true; - } else if (!closed && -1 !== index) { - items.splice(index, 1); - changed = true; + for (let i in entries) { + const entry = entries[i]; + + if (false !== entry.expiresAt && entry.expiresAt <= now) { + continue; + } + + const key = entry.key.toString(); + + if (!(key in items) || items[key] !== entry.expiresAt) { + items[key] = entry.expiresAt; + changed = true; + } } if (!changed) { return; } - if (items.length > this.#maxItems) { - items.splice(items.length - this.#maxItems); + const length = Object.values(items).length; + + if (length > this.#maxItems) { + Object.entries(items) + .sort((a, b) => (a[1] || Number.MAX_SAFE_INTEGER) - (b[1] || Number.MAX_SAFE_INTEGER)) + .slice(0, length - this.#maxItems) + .forEach(item => { + delete items[item[0]]; + }) } this.#loadedItems = items; this.#flush(); } - isClosed(bannerId) { - return -1 !== this.#getItems().indexOf(bannerId); + release(keys) { + const items = this.#getItems(); + let changed = false; + + for (let i in keys) { + const key = keys[i]; + const keyNative = key.toString(); + + if (keyNative in items) { + delete items[keyNative]; + changed = true; + } + } + + if (!changed) { + return; + } + + this.#loadedItems = items; + this.#flush(); + } + + isClosed(key) { + const items = this.#getItems(); + const keyNative = key.toString(); + + if (!(keyNative in items)) { + return false; + } + + if (false === items[keyNative] || items[keyNative] > this.#getNow()) { + return true; + } + + setTimeout(() => this.release([key]), 0); + + return false; } #getItems() { @@ -88,14 +144,19 @@ export class ClosedBannerStore { } const storedValue = this.#storage.getItem(this.#key); - const listOfItems = null !== storedValue && '' !== storedValue ? storedValue.split(',') : []; + let listOfItems = null !== storedValue && '' !== storedValue ? JSON.parse(storedValue) : {}; + + if (Array.isArray(listOfItems)) { // back compatibility - flush already persisted item + listOfItems = {}; + this.#storage.setItem(this.#key, '{}'); + } return this.#loadedItems = listOfItems; } #flush() { if (null !== this.#loadedItems) { - this.#storage.setItem(this.#key, this.#loadedItems.join(',')); + this.#storage.setItem(this.#key, JSON.stringify(this.#loadedItems)); } } @@ -115,4 +176,8 @@ export class ClosedBannerStore { return storage; } + + #getNow() { + return Math.round((+new Date() / 1000)); + } } diff --git a/src/banner/closing/closing-entry.mjs b/src/banner/closing/closing-entry.mjs new file mode 100644 index 0000000..a3ac94c --- /dev/null +++ b/src/banner/closing/closing-entry.mjs @@ -0,0 +1,89 @@ +export class ClosingEntry { + constructor({ key, expiresAt }) { + /** + * @type {EntryKey} + */ + this.key = key; + + /** + * @type {Number|Boolean} Integer or false + */ + this.expiresAt = expiresAt; + } + + static position({ positionCode, closingExpiration }) { + if ('number' !== typeof closingExpiration) { + throw new Error(`Unable to create position entry via factory ClosingEntry::position({ positionCode: ${positionCode}, closingExpiration: ${closingExpiration} }), argument "closingExpiration" must be a number.`); + } + + return new ClosingEntry({ + key: EntryKey.position(positionCode), + expiresAt: Math.round((+new Date() / 1000) + closingExpiration), + }); + } + + static banner({ positionCode, bannerId, closingExpiration }) { + return new ClosingEntry({ + key: EntryKey.banner(positionCode, bannerId), + expiresAt: 'number' === typeof closingExpiration ? Math.round((+new Date() / 1000) + closingExpiration) : false, + }); + } +} + +export class EntryKey { + constructor({ value, type, args }) { + this.value = value; + this.type = type; + + /** + * @type {{positionCode: {String}, bannerId?: {String}}} + */ + this.args = args; + } + + static position(positionCode) { + return new EntryKey({ + value: `p:${positionCode}`, + type: 'position', + args: { + positionCode: positionCode, + } + }); + } + + static banner(positionCode, bannerId) { + return new EntryKey({ + value: `b:${positionCode}:${bannerId}`, + type: 'banner', + args: { + positionCode: positionCode, + bannerId: bannerId, + } + }); + } + + static tryParse(keyNative) { + const parts = keyNative.split(':'); + + switch (true) { + case 'p' === parts[0] && 2 === parts.length: + return EntryKey.position(parts[1]); + case 'b' === parts[0] && 3 === parts.length: + return EntryKey.banner(parts[1], parts[2]); + } + + return null; + } + + isPosition() { + return 'position' === this.type; + } + + isBanner() { + return 'banner' === this.type; + } + + toString() { + return this.value; + } +} diff --git a/src/banner/closing/closing-manager.mjs b/src/banner/closing/closing-manager.mjs index 2a4d201..743642c 100644 --- a/src/banner/closing/closing-manager.mjs +++ b/src/banner/closing/closing-manager.mjs @@ -1,11 +1,14 @@ import { Events } from '../../event/events.mjs'; import { EmbedBanner } from '../embed/embed-banner.mjs'; import { ClosedBannerStore } from './closed-banner-store.mjs'; +import { ClosingEntry, EntryKey } from './closing-entry.mjs'; +import { Fingerprint } from '../fingerprint.mjs'; export class ClosingManager { #bannerManager; #eventBus; - #frameMessenger; + #bannerFrameMessenger; + #parentFrameMessenger; #store; constructor({ @@ -16,33 +19,52 @@ export class ClosingManager { key: 'amp-closed-banners', maxItems: 500, }, - frameMessenger = undefined, + bannerFrameMessenger = undefined, + parentFrameMessenger = undefined, }) { this.#bannerManager = bannerManager; this.#eventBus = eventBus; - this.#frameMessenger = frameMessenger; + this.#bannerFrameMessenger = bannerFrameMessenger; + this.#parentFrameMessenger = parentFrameMessenger; const { storage, key, maxItems } = config; this.#store = new ClosedBannerStore({ storage, key, maxItems, - onExternalChange: ids => { - ids.forEach(id => this.closeBanner(id)); + onExternalChange: keys => { + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + + if (key.isBanner()) { + this.closeBanner(key.args.positionCode, key.args.bannerId); + } + } }, }); - if (this.#frameMessenger) { - this.#frameMessenger.on('bannerClosed', ({ data }) => { - const { uid, bannerId, fingerprint } = data; + if (this.#bannerFrameMessenger) { + this.#bannerFrameMessenger.on('storeClosedEntries', ({ data }) => { + const { uid, entries, fingerprints } = data; const banner = this.#bannerManager.getBannerByUid(uid); if (!(banner instanceof EmbedBanner)) { return; } - this.#store.persist(bannerId, true); - banner.unsetFingerprint(fingerprint); + for (let i = 0; i < fingerprints.length; i++) { + banner.unsetFingerprint(Fingerprint.createFromValue(fingerprints[i])); + } + + this.#store.close(entries.map(e => new ClosingEntry({ key: EntryKey.tryParse(e.key), expiresAt: e.expiresAt }))); + }); + } + + if (this.#parentFrameMessenger) { + this.#parentFrameMessenger.on('closeBanner', ({ data }) => { + const { positionCode, bannerId } = data; + + positionCode && bannerId && this.closeBanner(positionCode, bannerId); }); } } @@ -67,7 +89,7 @@ export class ClosingManager { const bannerFingerprint = bannerFingerprints[i]; if (bannerFingerprint.value === fingerprint) { - this.closeBanner(bannerFingerprint.bannerId); + this.closeBanner(banner.position, bannerFingerprint.bannerId); } } }); @@ -78,62 +100,111 @@ export class ClosingManager { }, null, -100); } - isClosed(bannerId) { - return this.#store.isClosed(bannerId); + isBannerClosed(positionCode, bannerId) { + return this.#store.isClosed( + EntryKey.banner(positionCode, bannerId), + ); } - closeBanner(bannerId) { - const banners = this.#bannerManager.getBannersByState({ - state: this.#bannerManager.STATE.RENDERED, - }) + isPositionClosed(positionCode) { + return this.#store.isClosed( + EntryKey.position(positionCode), + ); + } - for (let i = 0; i < banners.length; i++) { - const banner = banners[i]; - const fingerprint = banner.fingerprints.filter(f => f.bannerId === bannerId)[0]; + closeBanner(positionCode, bannerId) { + const banner = this.#bannerManager.getBannerByPosition(positionCode); - if (undefined === fingerprint) { - continue; - } + if (null === banner) { + return; + } - if (banner instanceof EmbedBanner) { - this.#frameMessenger && (this.#frameMessenger.sendToBanner( - banner, - 'closeBanner', - { - bannerId: bannerId, - }, - )); + const allFingerprints = [...banner.fingerprints]; + const fingerprint = allFingerprints.filter(f => f.bannerId === bannerId)[0]; - return; - } + if (undefined === fingerprint) { + return; + } + + if (banner instanceof EmbedBanner) { + this.#bannerFrameMessenger && (this.#bannerFrameMessenger.sendToBanner( + banner, + 'closeBanner', + { + positionCode: positionCode, + bannerId: bannerId, + }, + )); + + return; + } + + const bannerExpiration = fingerprint.closeExpiration; + + this.#closeFingerprint({ banner, fingerprint }).then(() => { + const entries = []; + + entries.push(ClosingEntry.banner({ + positionCode, + bannerId, + closingExpiration: bannerExpiration, + })); - const element = banner.element.querySelector(`[data-amp-banner-fingerprint="${fingerprint.value}"]`); + if (null !== banner.positionData.closeExpiration) { + const promises = []; + const fingerprints = banner.fingerprints; - if (!element) { - continue; + for (let i = 0; i < fingerprints.length; i++) { + promises.push(this.#closeFingerprint({ banner, fingerprint: fingerprints[i] })); + } + + Promise.all(promises).then(() => { + entries.push(ClosingEntry.position({ + positionCode, + closingExpiration: banner.positionData.closeExpiration, + })); + + setTimeout(() => this.#store.close(entries), 0); + this.#parentFrameMessenger && this.#parentFrameMessenger.sendToParent('storeClosedEntries', { + entries: entries.map(e => ({ key: e.key.toString(), expiresAt: e.expiresAt })), + fingerprints: allFingerprints.map(f => f.toString()), + }); + }); + } else { + setTimeout(() => this.#store.close(entries), 0); + this.#parentFrameMessenger && this.#parentFrameMessenger.sendToParent('storeClosedEntries', { + entries: entries.map(e => ({ key: e.key.toString(), expiresAt: e.expiresAt })), + fingerprints: [fingerprint.toString()], + }); } + }); + } - let operation = (element) => element.remove(); - const setOperation = (op) => operation = op; + #closeFingerprint({ banner, fingerprint }) { + const element = banner.element.querySelector(`[data-amp-banner-fingerprint="${fingerprint.value}"]`); + + if (!element) { + return Promise.resolve(undefined); + } - this.#eventBus.dispatch(Events.ON_BANNER_BEFORE_CLOSE, { + let operation = (element) => element.remove(); + const setOperation = (op) => operation = op; + + this.#eventBus.dispatch(Events.ON_BANNER_BEFORE_CLOSE, { + fingerprint, + element, + banner, + setOperation, + }); + + return Promise.resolve(operation(element)).then(() => { + this.#eventBus.dispatch(Events.ON_BANNER_AFTER_CLOSE, { fingerprint, element, banner, - setOperation, }); - Promise.resolve(operation(element)).then(() => { - this.#store.persist(bannerId, true); - - this.#eventBus.dispatch(Events.ON_BANNER_AFTER_CLOSE, { - fingerprint, - element, - banner, - }); - - banner.unsetFingerprint(fingerprint); - }); - } + banner.unsetFingerprint(fingerprint); + }); } } diff --git a/src/banner/embed/embed-banner.mjs b/src/banner/embed/embed-banner.mjs index 32f1e28..70db698 100644 --- a/src/banner/embed/embed-banner.mjs +++ b/src/banner/embed/embed-banner.mjs @@ -39,7 +39,7 @@ export class EmbedBanner extends Banner { } updatePositionData(data) { - const props = ['id', 'name', 'rotationSeconds', 'displayType', 'breakpointType', 'dimensions']; + const props = ['id', 'name', 'rotationSeconds', 'displayType', 'breakpointType', 'closeExpiration']; const positionData = this.positionData; for (let prop of props) { diff --git a/src/banner/fingerprint.mjs b/src/banner/fingerprint.mjs index 5126fd2..d4e8466 100644 --- a/src/banner/fingerprint.mjs +++ b/src/banner/fingerprint.mjs @@ -1,5 +1,5 @@ export class Fingerprint { - constructor(value, { bannerId, bannerName, positionId, positionCode, positionName, campaignId, campaignCode, campaignName }) { + constructor(value, { bannerId, bannerName, positionId, positionCode, positionName, campaignId, campaignCode, campaignName, closeExpiration = null }) { this.value = value; this.bannerId = bannerId; this.bannerName = bannerName; @@ -9,6 +9,7 @@ export class Fingerprint { this.campaignId = campaignId; this.campaignCode = campaignCode; this.campaignName = campaignName; + this.closeExpiration = closeExpiration; } static createFromProperties(properties) { diff --git a/src/banner/managed/banner-data.mjs b/src/banner/managed/banner-data.mjs index 720190e..40b3253 100644 --- a/src/banner/managed/banner-data.mjs +++ b/src/banner/managed/banner-data.mjs @@ -64,6 +64,10 @@ export class BannerData { return this.#data.campaign_name || null; } + get closeExpiration() { + return 'close_expiration' in this.#data ? this.#data.close_expiration : null; + } + get content() { return this.#contents.content.data; } diff --git a/src/banner/managed/managed-banner.mjs b/src/banner/managed/managed-banner.mjs index 17862e4..f87634a 100644 --- a/src/banner/managed/managed-banner.mjs +++ b/src/banner/managed/managed-banner.mjs @@ -122,6 +122,7 @@ export class ManagedBanner extends Banner { campaignId: bannerData.campaignId, campaignCode: bannerData.campaignCode, campaignName: bannerData.campaignName, + closeExpiration: bannerData.closeExpiration, }); switch (true) { @@ -197,6 +198,7 @@ export class ManagedBanner extends Banner { rotationSeconds: responseData['rotation_seconds'], displayType: responseData['display_type'], breakpointType: responseData['breakpoint_type'], + closeExpiration: responseData['close_expiration'] || null, }); if ('options' in responseData) { diff --git a/src/banner/position-data.mjs b/src/banner/position-data.mjs index d94df00..f82482c 100644 --- a/src/banner/position-data.mjs +++ b/src/banner/position-data.mjs @@ -6,14 +6,16 @@ export class PositionData { * @param {int} rotationSeconds * @param {string|null} displayType * @param {string|null} breakpointType + * @param {int|null} closeExpiration */ - constructor({ id, code, name, rotationSeconds, displayType, breakpointType }) { + constructor({ id, code, name, rotationSeconds, displayType, breakpointType, closeExpiration = null }) { this.id = id; this.code = code; this.name = name; this.rotationSeconds = rotationSeconds; this.displayType = displayType; this.breakpointType = breakpointType; + this.closeExpiration = closeExpiration; } static createInitial(code) { @@ -24,6 +26,7 @@ export class PositionData { rotationSeconds: 0, displayType: null, breakpointType: null, + closeExpiration: null, }); } @@ -47,6 +50,7 @@ export class PositionData { rotationSeconds: this.rotationSeconds, displayType: this.displayType, breakpointType: this.breakpointType, + closeExpiration: this.closeExpiration, } } } diff --git a/src/client/embed/client.mjs b/src/client/embed/client.mjs index f30a8ef..5895cf3 100644 --- a/src/client/embed/client.mjs +++ b/src/client/embed/client.mjs @@ -43,19 +43,18 @@ export class Client { return this.#parentWindowWidth || window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth; }), ); + this.#frameMessenger = new ParentFrameMessenger({ + clientEventBus: this.#eventBus, + }); this.#closingManager = new ClosingManager({ bannerManager: this.#bannerManager, eventBus: this.#eventBus, - }); - this.#frameMessenger = new ParentFrameMessenger({ - clientEventBus: this.#eventBus, - closingManager: this.#closingManager, + parentFrameMessenger: this.#frameMessenger, }); this.#attached = false; this.#bannerInteractionWatcher = null; this.#metricsSender = new MetricsSender( [this.#sendMetricsEvent.bind(this)], - [], ); this.#metricsEventsListener = new MetricsEventsListener( this.#metricsSender, diff --git a/src/client/standard/client.mjs b/src/client/standard/client.mjs index d4ea60b..3945295 100644 --- a/src/client/standard/client.mjs +++ b/src/client/standard/client.mjs @@ -99,7 +99,7 @@ export class Client { key: options.closing.key, maxItems: options.closing.maxItems, }, - frameMessenger: this.#frameMessenger, + bannerFrameMessenger: this.#frameMessenger, }); this.setLocale(options.locale); @@ -184,8 +184,8 @@ export class Client { return this.#bannerManager.addManagedBanner(element, position, resources, options, refWindow); } - closeBanner(bannerId) { - this.#closingManager.closeBanner(bannerId); + closeBanner(positionCode, bannerId) { + this.#closingManager.closeBanner(positionCode, bannerId); } attachBanners(snippet = document, refWindow = window) { @@ -248,18 +248,23 @@ export class Client { } const positionData = data[banner.position]; - const banners = Array.isArray(positionData['banners']) ? positionData['banners'] : Object.values(positionData['banners']); - const validBanners = []; - for (let i = 0; i < banners.length; i++) { - const banner = banners[i]; + if (this.#closingManager.isPositionClosed(banner.position)) { + positionData.banners = []; + } else { + const banners = Array.isArray(positionData['banners']) ? positionData['banners'] : Object.values(positionData['banners']); + const validBanners = []; - if (banner.id && !this.#closingManager.isClosed(banner.id)) { - validBanners.push(banner); + for (let i = 0; i < banners.length; i++) { + const bannerData = banners[i]; + + if (bannerData.id && !this.#closingManager.isBannerClosed(banner.position, bannerData.id)) { + validBanners.push(bannerData); + } } - } - positionData.banners = validBanners; + positionData.banners = validBanners; + } if ('embed' === positionData.mode && 0 < positionData.banners.length) { if ('options' in positionData) { diff --git a/src/frame/parent-frame-messenger.mjs b/src/frame/parent-frame-messenger.mjs index ad17ee5..db51850 100644 --- a/src/frame/parent-frame-messenger.mjs +++ b/src/frame/parent-frame-messenger.mjs @@ -4,35 +4,30 @@ import { State } from '../banner/state.mjs'; export class ParentFrameMessenger extends FrameMessenger { #clientEventBus; - #closingManager; #origin; #uid; #parentMessagesQueue; /** * @param {EventBus} clientEventBus - * @param {ClosingManager} closingManager * @param {String|undefined} origin */ - constructor({ clientEventBus, closingManager, origin = undefined }) { + constructor({ clientEventBus, origin = undefined }) { super({ origins: undefined !== origin ? [origin] : [], }); this.#clientEventBus = clientEventBus; - this.#closingManager = closingManager; this.#origin = origin; this.#uid = null; this.#parentMessagesQueue = []; const messageHandlers = {}; messageHandlers['connect'] = this.#onConnectMessage; - messageHandlers['closeBanner'] = this.#onCloseBannerMessage; const eventHandlers = {}; eventHandlers[Events.ON_BANNER_STATE_CHANGED] = [this.#onBannerStateChangeEvent, 0]; eventHandlers[Events.ON_BANNER_LINK_CLICKED] = [this.#onBannerLinkClickedEvent, -100]; - eventHandlers[Events.ON_BANNER_AFTER_CLOSE] = [this.#onBannerAfterCloseEvent, 0]; for (let eventName in eventHandlers) { this.#clientEventBus.subscribe(eventName, eventHandlers[eventName][0].bind(this), null, eventHandlers[eventName][1]); @@ -88,12 +83,6 @@ export class ParentFrameMessenger extends FrameMessenger { this.#releaseQueuedParentMessages(); } - #onCloseBannerMessage({ data }) { - const { bannerId } = data; - - bannerId && this.#closingManager.closeBanner(bannerId); - } - #onBannerStateChangeEvent({ banner }) { if (State.NEW === banner.state) { return; @@ -121,13 +110,6 @@ export class ParentFrameMessenger extends FrameMessenger { }); } - #onBannerAfterCloseEvent({ fingerprint }) { - this.sendToParent('bannerClosed', { - bannerId: fingerprint.bannerId, - fingerprint: fingerprint.value, - }); - } - #releaseQueuedParentMessages() { for (let message of this.#parentMessagesQueue) { this.sendToParent(message.message, message.data);