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
5import { actionCreators as ac, actionTypes as at } from "common/Actions.jsm";
6import React from "react";
7
8const VISIBLE = "visible";
9const VISIBILITY_CHANGE_EVENT = "visibilitychange";
10
11// Per analytical requirement, we set the minimal intersection ratio to
12// 0.5, and an impression is identified when the wrapped item has at least
13// 50% visibility.
14//
15// This constant is exported for unit test
16export const INTERSECTION_RATIO = 0.5;
17
18/**
19 * Impression wrapper for a TopSite tile.
20 *
21 * It makses use of the Intersection Observer API to detect the visibility,
22 * and relies on page visibility to ensure the impression is reported
23 * only when the component is visible on the page.
24 */
25export class TopSiteImpressionWrapper extends React.PureComponent {
26  _dispatchImpressionStats() {
27    const { tile } = this.props;
28
29    this.props.dispatch(
30      ac.OnlyToMain({
31        type: at.TOP_SITES_IMPRESSION_STATS,
32        data: {
33          type: "impression",
34          ...tile,
35        },
36      })
37    );
38  }
39
40  setImpressionObserverOrAddListener() {
41    const { props } = this;
42
43    if (!props.dispatch) {
44      return;
45    }
46
47    if (props.document.visibilityState === VISIBLE) {
48      this.setImpressionObserver();
49    } else {
50      // We should only ever send the latest impression stats ping, so remove any
51      // older listeners.
52      if (this._onVisibilityChange) {
53        props.document.removeEventListener(
54          VISIBILITY_CHANGE_EVENT,
55          this._onVisibilityChange
56        );
57      }
58
59      this._onVisibilityChange = () => {
60        if (props.document.visibilityState === VISIBLE) {
61          this.setImpressionObserver();
62          props.document.removeEventListener(
63            VISIBILITY_CHANGE_EVENT,
64            this._onVisibilityChange
65          );
66        }
67      };
68      props.document.addEventListener(
69        VISIBILITY_CHANGE_EVENT,
70        this._onVisibilityChange
71      );
72    }
73  }
74
75  /**
76   * Set an impression observer for the wrapped component. It makes use of
77   * the Intersection Observer API to detect if the wrapped component is
78   * visible with a desired ratio, and only sends impression if that's the case.
79   *
80   * See more details about Intersection Observer API at:
81   * https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
82   */
83  setImpressionObserver() {
84    const { props } = this;
85
86    if (!props.tile) {
87      return;
88    }
89
90    this._handleIntersect = entries => {
91      if (
92        entries.some(
93          entry =>
94            entry.isIntersecting &&
95            entry.intersectionRatio >= INTERSECTION_RATIO
96        )
97      ) {
98        this._dispatchImpressionStats();
99        this.impressionObserver.unobserve(this.refs.topsite_impression_wrapper);
100      }
101    };
102
103    const options = { threshold: INTERSECTION_RATIO };
104    this.impressionObserver = new props.IntersectionObserver(
105      this._handleIntersect,
106      options
107    );
108    this.impressionObserver.observe(this.refs.topsite_impression_wrapper);
109  }
110
111  componentDidMount() {
112    if (this.props.tile) {
113      this.setImpressionObserverOrAddListener();
114    }
115  }
116
117  componentWillUnmount() {
118    if (this._handleIntersect && this.impressionObserver) {
119      this.impressionObserver.unobserve(this.refs.topsite_impression_wrapper);
120    }
121    if (this._onVisibilityChange) {
122      this.props.document.removeEventListener(
123        VISIBILITY_CHANGE_EVENT,
124        this._onVisibilityChange
125      );
126    }
127  }
128
129  render() {
130    return (
131      <div
132        ref={"topsite_impression_wrapper"}
133        className="topsite-impression-observer"
134      >
135        {this.props.children}
136      </div>
137    );
138  }
139}
140
141TopSiteImpressionWrapper.defaultProps = {
142  IntersectionObserver: global.IntersectionObserver,
143  document: global.document,
144  tile: null,
145};
146