From 36d22d82aa202bb199967e9512281e9a53db42c9 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 21:33:14 +0200 Subject: Adding upstream version 115.7.0esr. Signed-off-by: Daniel Baumann --- .../ImpressionStats.jsx | 250 +++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx (limited to 'browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx') diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx new file mode 100644 index 0000000000..b385c2ebf7 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx @@ -0,0 +1,250 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { TOP_SITES_SOURCE } from "../TopSites/TopSitesConstants"; +import React from "react"; + +const VISIBLE = "visible"; +const VISIBILITY_CHANGE_EVENT = "visibilitychange"; + +// Per analytical requirement, we set the minimal intersection ratio to +// 0.5, and an impression is identified when the wrapped item has at least +// 50% visibility. +// +// This constant is exported for unit test +export const INTERSECTION_RATIO = 0.5; + +/** + * Impression wrapper for Discovery Stream related React components. + * + * It makses use of the Intersection Observer API to detect the visibility, + * and relies on page visibility to ensure the impression is reported + * only when the component is visible on the page. + * + * Note: + * * This wrapper used to be used either at the individual card level, + * or by the card container components. + * It is now only used for individual card level. + * * Each impression will be sent only once as soon as the desired + * visibility is detected + * * Batching is not yet implemented, hence it might send multiple + * impression pings separately + */ +export class ImpressionStats extends React.PureComponent { + // This checks if the given cards are the same as those in the last impression ping. + // If so, it should not send the same impression ping again. + _needsImpressionStats(cards) { + if ( + !this.impressionCardGuids || + this.impressionCardGuids.length !== cards.length + ) { + return true; + } + + for (let i = 0; i < cards.length; i++) { + if (cards[i].id !== this.impressionCardGuids[i]) { + return true; + } + } + + return false; + } + + _dispatchImpressionStats() { + const { props } = this; + const cards = props.rows; + + if (this.props.flightId) { + this.props.dispatch( + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_SPOC_IMPRESSION, + data: { flightId: this.props.flightId }, + }) + ); + + // Record sponsored topsites impressions if the source is `TOP_SITES_SOURCE`. + if (this.props.source === TOP_SITES_SOURCE) { + for (const card of cards) { + this.props.dispatch( + ac.OnlyToMain({ + type: at.TOP_SITES_SPONSORED_IMPRESSION_STATS, + data: { + type: "impression", + tile_id: card.id, + source: "newtab", + advertiser: card.advertiser, + // Keep the 0-based position, can be adjusted by the telemetry + // sender if necessary. + position: card.pos, + }, + }) + ); + } + } + } + + if (this._needsImpressionStats(cards)) { + props.dispatch( + ac.DiscoveryStreamImpressionStats({ + source: props.source.toUpperCase(), + window_inner_width: window.innerWidth, + window_inner_height: window.innerHeight, + tiles: cards.map(link => ({ + id: link.id, + pos: link.pos, + type: this.props.flightId ? "spoc" : "organic", + ...(link.shim ? { shim: link.shim } : {}), + })), + }) + ); + this.impressionCardGuids = cards.map(link => link.id); + } + } + + // This checks if the given cards are the same as those in the last loaded content ping. + // If so, it should not send the same loaded content ping again. + _needsLoadedContent(cards) { + if ( + !this.loadedContentGuids || + this.loadedContentGuids.length !== cards.length + ) { + return true; + } + + for (let i = 0; i < cards.length; i++) { + if (cards[i].id !== this.loadedContentGuids[i]) { + return true; + } + } + + return false; + } + + _dispatchLoadedContent() { + const { props } = this; + const cards = props.rows; + + if (this._needsLoadedContent(cards)) { + props.dispatch( + ac.DiscoveryStreamLoadedContent({ + source: props.source.toUpperCase(), + tiles: cards.map(link => ({ id: link.id, pos: link.pos })), + }) + ); + this.loadedContentGuids = cards.map(link => link.id); + } + } + + setImpressionObserverOrAddListener() { + const { props } = this; + + if (!props.dispatch) { + return; + } + + if (props.document.visibilityState === VISIBLE) { + // Send the loaded content ping once the page is visible. + this._dispatchLoadedContent(); + this.setImpressionObserver(); + } else { + // We should only ever send the latest impression stats ping, so remove any + // older listeners. + if (this._onVisibilityChange) { + props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + + this._onVisibilityChange = () => { + if (props.document.visibilityState === VISIBLE) { + // Send the loaded content ping once the page is visible. + this._dispatchLoadedContent(); + this.setImpressionObserver(); + props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + }; + props.document.addEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } + + /** + * Set an impression observer for the wrapped component. It makes use of + * the Intersection Observer API to detect if the wrapped component is + * visible with a desired ratio, and only sends impression if that's the case. + * + * See more details about Intersection Observer API at: + * https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API + */ + setImpressionObserver() { + const { props } = this; + + if (!props.rows.length) { + return; + } + + this._handleIntersect = entries => { + if ( + entries.some( + entry => + entry.isIntersecting && + entry.intersectionRatio >= INTERSECTION_RATIO + ) + ) { + this._dispatchImpressionStats(); + this.impressionObserver.unobserve(this.refs.impression); + } + }; + + const options = { threshold: INTERSECTION_RATIO }; + this.impressionObserver = new props.IntersectionObserver( + this._handleIntersect, + options + ); + this.impressionObserver.observe(this.refs.impression); + } + + componentDidMount() { + if (this.props.rows.length) { + this.setImpressionObserverOrAddListener(); + } + } + + componentWillUnmount() { + if (this._handleIntersect && this.impressionObserver) { + this.impressionObserver.unobserve(this.refs.impression); + } + if (this._onVisibilityChange) { + this.props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } + + render() { + return ( +
+ {this.props.children} +
+ ); + } +} + +ImpressionStats.defaultProps = { + IntersectionObserver: global.IntersectionObserver, + document: global.document, + rows: [], + source: "", +}; -- cgit v1.2.3