tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 };