ImpressionStats.jsx (8473B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; 6 import { getActiveCardSize } from "../../lib/utils"; 7 import { TOP_SITES_SOURCE } from "../TopSites/TopSitesConstants"; 8 import React from "react"; 9 10 const VISIBLE = "visible"; 11 const VISIBILITY_CHANGE_EVENT = "visibilitychange"; 12 13 // Per analytical requirement, we set the minimal intersection ratio to 14 // 0.5, and an impression is identified when the wrapped item has at least 15 // 50% visibility. 16 // 17 // This constant is exported for unit test 18 export const INTERSECTION_RATIO = 0.5; 19 20 /** 21 * Impression wrapper for Discovery Stream related React components. 22 * 23 * It makes use of the Intersection Observer API to detect the visibility, 24 * and relies on page visibility to ensure the impression is reported 25 * only when the component is visible on the page. 26 * 27 * Note: 28 * * This wrapper used to be used either at the individual card level, 29 * or by the card container components. 30 * It is now only used for individual card level. 31 * * Each impression will be sent only once as soon as the desired 32 * visibility is detected 33 * * Batching is not yet implemented, hence it might send multiple 34 * impression pings separately 35 */ 36 export class ImpressionStats extends React.PureComponent { 37 // This checks if the given cards are the same as those in the last impression ping. 38 // If so, it should not send the same impression ping again. 39 _needsImpressionStats(cards) { 40 if ( 41 !this.impressionCardGuids || 42 this.impressionCardGuids.length !== cards.length 43 ) { 44 return true; 45 } 46 47 for (let i = 0; i < cards.length; i++) { 48 if (cards[i].id !== this.impressionCardGuids[i]) { 49 return true; 50 } 51 } 52 53 return false; 54 } 55 56 _dispatchImpressionStats() { 57 const { props } = this; 58 const cards = props.rows; 59 60 if (this.props.flightId) { 61 this.props.dispatch( 62 ac.OnlyToMain({ 63 type: at.DISCOVERY_STREAM_SPOC_IMPRESSION, 64 data: { flightId: this.props.flightId }, 65 }) 66 ); 67 68 // Record sponsored topsites impressions if the source is `TOP_SITES_SOURCE`. 69 if (this.props.source === TOP_SITES_SOURCE) { 70 for (const card of cards) { 71 this.props.dispatch( 72 ac.OnlyToMain({ 73 type: at.TOP_SITES_SPONSORED_IMPRESSION_STATS, 74 data: { 75 type: "impression", 76 tile_id: card.id, 77 source: "newtab", 78 advertiser: card.advertiser, 79 // Keep the 0-based position, can be adjusted by the telemetry 80 // sender if necessary. 81 position: card.pos, 82 attribution: card.attribution, 83 }, 84 }) 85 ); 86 } 87 } 88 } 89 90 if (this._needsImpressionStats(cards)) { 91 props.dispatch( 92 ac.DiscoveryStreamImpressionStats({ 93 source: props.source.toUpperCase(), 94 window_inner_width: window.innerWidth, 95 window_inner_height: window.innerHeight, 96 tiles: cards.map(link => ({ 97 id: link.id, 98 pos: link.pos, 99 type: props.flightId ? "spoc" : "organic", 100 ...(link.shim ? { shim: link.shim } : {}), 101 recommendation_id: link.recommendation_id, 102 fetchTimestamp: link.fetchTimestamp, 103 corpus_item_id: link.corpus_item_id, 104 scheduled_corpus_item_id: link.scheduled_corpus_item_id, 105 recommended_at: link.recommended_at, 106 received_rank: link.received_rank, 107 topic: link.topic, 108 features: link.features, 109 attribution: link.attribution, 110 ...(link.format 111 ? { format: link.format } 112 : { 113 format: getActiveCardSize( 114 window.innerWidth, 115 link.class_names, 116 link.section, 117 link.flightId 118 ), 119 }), 120 ...(link.section 121 ? { 122 section: link.section, 123 section_position: link.section_position, 124 is_section_followed: link.is_section_followed, 125 layout_name: link.sectionLayoutName, 126 } 127 : {}), 128 })), 129 firstVisibleTimestamp: props.firstVisibleTimestamp, 130 }) 131 ); 132 this.impressionCardGuids = cards.map(link => link.id); 133 } 134 } 135 136 // This checks if the given cards are the same as those in the last loaded content ping. 137 // If so, it should not send the same loaded content ping again. 138 _needsLoadedContent(cards) { 139 if ( 140 !this.loadedContentGuids || 141 this.loadedContentGuids.length !== cards.length 142 ) { 143 return true; 144 } 145 146 for (let i = 0; i < cards.length; i++) { 147 if (cards[i].id !== this.loadedContentGuids[i]) { 148 return true; 149 } 150 } 151 152 return false; 153 } 154 155 _dispatchLoadedContent() { 156 const { props } = this; 157 const cards = props.rows; 158 159 if (this._needsLoadedContent(cards)) { 160 props.dispatch( 161 ac.DiscoveryStreamLoadedContent({ 162 source: props.source.toUpperCase(), 163 tiles: cards.map(link => ({ id: link.id, pos: link.pos })), 164 }) 165 ); 166 this.loadedContentGuids = cards.map(link => link.id); 167 } 168 } 169 170 setImpressionObserverOrAddListener() { 171 const { props } = this; 172 173 if (!props.dispatch) { 174 return; 175 } 176 177 if (props.document.visibilityState === VISIBLE) { 178 // Send the loaded content ping once the page is visible. 179 this._dispatchLoadedContent(); 180 this.setImpressionObserver(); 181 } else { 182 // We should only ever send the latest impression stats ping, so remove any 183 // older listeners. 184 if (this._onVisibilityChange) { 185 props.document.removeEventListener( 186 VISIBILITY_CHANGE_EVENT, 187 this._onVisibilityChange 188 ); 189 } 190 191 this._onVisibilityChange = () => { 192 if (props.document.visibilityState === VISIBLE) { 193 // Send the loaded content ping once the page is visible. 194 this._dispatchLoadedContent(); 195 this.setImpressionObserver(); 196 props.document.removeEventListener( 197 VISIBILITY_CHANGE_EVENT, 198 this._onVisibilityChange 199 ); 200 } 201 }; 202 props.document.addEventListener( 203 VISIBILITY_CHANGE_EVENT, 204 this._onVisibilityChange 205 ); 206 } 207 } 208 209 /** 210 * Set an impression observer for the wrapped component. It makes use of 211 * the Intersection Observer API to detect if the wrapped component is 212 * visible with a desired ratio, and only sends impression if that's the case. 213 * 214 * See more details about Intersection Observer API at: 215 * https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API 216 */ 217 setImpressionObserver() { 218 const { props } = this; 219 220 if (!props.rows.length) { 221 return; 222 } 223 224 this._handleIntersect = entries => { 225 if ( 226 entries.some( 227 entry => 228 entry.isIntersecting && 229 entry.intersectionRatio >= INTERSECTION_RATIO 230 ) 231 ) { 232 this._dispatchImpressionStats(); 233 this.impressionObserver.unobserve(this.refs.impression); 234 } 235 }; 236 237 const options = { threshold: INTERSECTION_RATIO }; 238 this.impressionObserver = new props.IntersectionObserver( 239 this._handleIntersect, 240 options 241 ); 242 this.impressionObserver.observe(this.refs.impression); 243 } 244 245 componentDidMount() { 246 if (this.props.rows.length) { 247 this.setImpressionObserverOrAddListener(); 248 } 249 } 250 251 componentWillUnmount() { 252 if (this._handleIntersect && this.impressionObserver) { 253 this.impressionObserver.unobserve(this.refs.impression); 254 } 255 if (this._onVisibilityChange) { 256 this.props.document.removeEventListener( 257 VISIBILITY_CHANGE_EVENT, 258 this._onVisibilityChange 259 ); 260 } 261 } 262 263 render() { 264 return ( 265 <div ref={"impression"} className="impression-observer"> 266 {this.props.children} 267 </div> 268 ); 269 } 270 } 271 272 ImpressionStats.defaultProps = { 273 IntersectionObserver: globalThis.IntersectionObserver, 274 document: globalThis.document, 275 rows: [], 276 source: "", 277 };