diff --git a/components/x-live-blog-wrapper/readme.md b/components/x-live-blog-wrapper/readme.md
index e9d8411b2..28178d23d 100644
--- a/components/x-live-blog-wrapper/readme.md
+++ b/components/x-live-blog-wrapper/readme.md
@@ -18,7 +18,15 @@ The [`x-engine`][engine] module is used to inject your chosen runtime into the c
## Usage
-The components provided by this module are all functions that expect a map of [properties](#properties). They can be used with vanilla JavaScript or JSX (If you are not familiar check out [WTF is JSX][jsx-wtf] first). For example if you were writing your application using React you could use the component like this:
+The components provided by this module are all functions that expect a map of [properties](#properties). They can be used with vanilla JavaScript or JSX (If you are not familiar check out [WTF is JSX][jsx-wtf] first). Also worth noting, this component handles visibility tracking when passed `postTrackerConfig` property. The `postTrackerConfig` property contains the follow fields:
+- onEntersViewport: Callback function with an event parameter
+- OnRead: Callback function with an event parameter
+- OnError: Callback function with an event parameter
+- usePostTracker: Boolean. When set to `true` LiveBlogWrapper component creates and manages an instance of PostTracker and reports read, view, error events.
+All fields are required to use post tracking.
+
+
+ For example if you were writing your application using React you could use the component like this:
```jsx
import React from 'react';
@@ -34,6 +42,36 @@ All `x-` components are designed to be compatible with a variety of runtimes, no
[jsx-wtf]: https://jasonformat.com/wtf-is-jsx/
+The `x-live-blog-wrapper` component also exports `PostTracker`, a class used to track post visibility. It reports a read and a view event for individual posts in the `LiveBlogWrapper` component. If you choose to handle post tracking yourself, this class should be used as an alternative.
+
+```js
+import { PostTracker } from '@financial-times/x-live-blog-wrapper';
+
+const onEntersViewPort = (event) => {} // Enrich event with app context and report to tracking medium.
+const onRead = (event) => {} // Enrich event with app context and report to tracking medium.
+const onError = (event) => {} // Enrich event with app context and report to tracking medium.
+const liveBlogPackageId = '00000-00000-00000-00000'
+
+/**
+ * @type {import('@financial-times/x-live-blog-wrapper').PostTracker.PostTrackerConfig}
+ */
+let config = {
+ query: 'article[data-trackable="live-post"]', // required
+ minMillisecondsToReport: 5000,
+ returnVisibleElement: true,
+ observerUpdateEventString: 'LiveBlogWrapper.INSERT_POST', // required
+ liveBlogWrapperQuery: `div[data-live-blog-wrapper-id="${liveBlogPackageId}"]`, // required, where id = liveblogpackage.id
+ liveBlogWrapper: this.props.liveBlogWrapperElementRef
+ ? this.props.liveBlogWrapperElementRef.current
+ : undefined,
+ onEntersViewport: (event) => callbacks.onEntersViewport(event), // required
+ onRead: (event) => callbacks.onRead(event), // required
+ onError: (event) => callbacks.onError(event) // required
+}
+new PostTracker(config);
+
+```
+
### Client side rendering
This component can be used at the client side.
diff --git a/components/x-live-blog-wrapper/src/LiveBlogWrapper.jsx b/components/x-live-blog-wrapper/src/LiveBlogWrapper.jsx
index e76727ca3..75d93dfa0 100644
--- a/components/x-live-blog-wrapper/src/LiveBlogWrapper.jsx
+++ b/components/x-live-blog-wrapper/src/LiveBlogWrapper.jsx
@@ -1,9 +1,19 @@
-import { h } from '@financial-times/x-engine'
+import { h, Component } from '@financial-times/x-engine'
import { LiveBlogPost } from '@financial-times/x-live-blog-post'
import { withActions } from '@financial-times/x-interaction'
import { normalisePost } from './normalisePost'
import { dispatchEvent } from './dispatchEvent'
import { registerComponent } from '@financial-times/x-interaction'
+import { PostTracker } from './lib/post-tracker'
+
+/**
+ * @typedef PostTrackerConfig
+ * @type {object}
+ * @property {Function} onEntersViewport - function to be called when the criteria for `view` in Post tracker has been fulfiled
+ * @property {Function} onRead - function to be called when the criteria for `read` in Post tracker has been fulfiled
+ * @property {Function} onError - function to be called when PostTracker encounters an error
+ * @property {Boolean} usePostTracker - condition checked before creating an instance of post tracker
+ */
const withLiveBlogWrapperActions = withActions({
insertPost(newPost, wrapper) {
@@ -20,45 +30,124 @@ const withLiveBlogWrapperActions = withActions({
}
})
-const BaseLiveBlogWrapper = ({
- posts = [],
- ads = {},
- articleUrl,
- showShareButtons,
- id,
- liveBlogWrapperElementRef
-}) => {
- posts.sort((a, b) => {
- const timestampA = a.publishedDate || a.publishedTimestamp
- const timestampB = b.publishedDate || b.publishedTimestamp
-
- // Newer posts on top
- if (timestampA > timestampB) {
- return -1
+class BaseLiveBlogWrapper extends Component {
+ /**
+ * @param {Object} props
+ * @param {Object[]} props.post - A list of liveblog posts
+ * @param {Object} props.ads
+ * @param {string} props.articleUrl - The base url for the article
+ * @param {boolean} props.showShareButtons - condition to show shareButtons
+ * @param {string} props.id - The id of the liveblog package
+ * @param {*} props.liveBlogWrapperElementRef
+ * @param {PostTrackerConfig} props.postTrackerConfig - Optional config for tracking post
+ */
+ constructor(props) {
+ super(props)
+ }
+
+ componentDidMount() {
+ this.setUp()
+ }
+
+ componentWillUnmount() {
+ if (this.state.tracker) {
+ this.state.tracker.destroy()
+ }
+ }
+
+ setUp() {
+ const { onEntersViewport, onRead, onError, usePostTracker } = this.props.postTrackerConfig || {}
+
+ if (!usePostTracker) {
+ return
}
- if (timestampB > timestampA) {
- return 1
+ if (
+ typeof onEntersViewport !== 'function' ||
+ typeof onRead !== 'function' ||
+ typeof onError !== 'function'
+ ) {
+ // eslint-disable-next-line no-console
+ console.error(
+ 'onEntersViewport, onRead and onError callback functions are required to use Post tracker'
+ )
+ return
+ }
+
+ const tracker = this.setUpPostTracking({ onEntersViewport, onRead, onError }, this.props.id)
+ this.setState({
+ tracker
+ })
+ }
+
+ /**
+ *
+ * @param {Object} callbacks - Callbacks to be called by PostTracker class
+ * @param {Function} callbacks.onEntersViewport - function to be called when the criteria for `view` in Post tracker has been fulfiled
+ * @param {Function} callbacks.onRead - function to be called when the criteria for `read` in Post tracker has been fulfiled
+ * @param {Function} callbacks.onError - function to be called when PostTracker encounters an error
+ * @param {string} id
+ * @returns {PostTracker}
+ */
+ setUpPostTracking(callbacks, id) {
+ /**
+ * @type {import('./lib/post-tracker').PostTrackerConfig}
+ */
+ let config = {
+ query: 'article[data-trackable="live-post"]',
+ minMillisecondsToReport: 5000,
+ returnVisibleElement: true,
+ observerUpdateEventString: 'LiveBlogWrapper.INSERT_POST',
+ liveBlogWrapperQuery: `div[data-live-blog-wrapper-id="${id}"]`,
+ liveBlogWrapper: this.props.liveBlogWrapperElementRef
+ ? this.props.liveBlogWrapperElementRef.current
+ : undefined,
+ onEntersViewport: (event) => callbacks.onEntersViewport(event),
+ onRead: (event) => callbacks.onRead(event),
+ onError: (event) => callbacks.onError(event)
}
- return 0
- })
-
- const postElements = posts.map((post, index) => (
-
- ))
-
- return (
-
- {postElements}
-
- )
+ /**
+ * @type {PostTracker}
+ */
+ return new PostTracker(config)
+ }
+
+ render() {
+ const { posts = [], ads = {}, articleUrl, showShareButtons, id, liveBlogWrapperElementRef } = this.props
+
+ posts.sort((a, b) => {
+ const timestampA = a.publishedDate || a.publishedTimestamp
+ const timestampB = b.publishedDate || b.publishedTimestamp
+
+ // Newer posts on top
+ if (timestampA > timestampB) {
+ return -1
+ }
+
+ if (timestampB > timestampA) {
+ return 1
+ }
+
+ return 0
+ })
+
+ const postElements = posts.map((post, index) => (
+
+ ))
+
+ return (
+
+ {postElements}
+
+ )
+ }
}
const LiveBlogWrapper = withLiveBlogWrapperActions(BaseLiveBlogWrapper)
@@ -66,4 +155,4 @@ const LiveBlogWrapper = withLiveBlogWrapperActions(BaseLiveBlogWrapper)
// This enables the component to work with x-interaction hydration
registerComponent(LiveBlogWrapper, 'LiveBlogWrapper')
-export { LiveBlogWrapper }
+export { LiveBlogWrapper, PostTracker }
diff --git a/components/x-live-blog-wrapper/src/__tests__/post-tracker.test.js b/components/x-live-blog-wrapper/src/__tests__/post-tracker.test.js
new file mode 100644
index 000000000..9126dfb5b
--- /dev/null
+++ b/components/x-live-blog-wrapper/src/__tests__/post-tracker.test.js
@@ -0,0 +1,95 @@
+import { PostTracker } from '../lib/post-tracker'
+const jsdom = require('jsdom')
+const { JSDOM } = jsdom
+
+const doc = new JSDOM(
+ ' {})
+ new PostTracker({ query: 'article[data-trackable="live-post"]', onError: spy })
+ expect(spy).toHaveBeenCalledTimes(1)
+ })
+
+ it('should emit an error when a query is not passed to config', function () {
+ setupIntersectionObserverMock()
+ global.window = doc
+ global.window.addEventListener = jest.fn(() => {})
+ global.document = doc.window
+ global.document.querySelectorAll = jest.fn(() => [{}])
+ let spy = jest.fn(() => {})
+ new PostTracker({ onError: spy })
+ expect(spy).toHaveBeenCalledTimes(1)
+ })
+
+ it("should emit an error when a query doesn't match any elements ", function () {
+ setupIntersectionObserverMock()
+ let spy = jest.fn(() => {})
+ global.window = doc
+ global.window.addEventListener = jest.fn(() => {})
+
+ global.document = doc.window
+ global.document.querySelector = jest.fn(() => {
+ return { querySelectorAll: jest.fn(() => []) }
+ })
+ new PostTracker({ query: 'lord-of-the-rings', onError: spy })
+ expect(spy).toHaveBeenCalledTimes(1)
+ })
+})
+
+function setupIntersectionObserverMock({
+ root = null,
+ rootMargin = '',
+ thresholds = [],
+ disconnect = () => null,
+ observe = () => null,
+ takeRecords = () => [],
+ unobserve = () => null
+} = {}) {
+ class MockIntersectionObserver {
+ constructor() {
+ this.root = root
+ this.rootMargin = rootMargin
+ this.thresholds = thresholds
+ this.disconnect = disconnect
+ this.observe = observe
+ this.takeRecords = takeRecords
+ this.unobserve = unobserve
+ }
+ }
+
+ Object.defineProperty(window, 'IntersectionObserver', {
+ writable: true,
+ configurable: true,
+ value: MockIntersectionObserver
+ })
+
+ Object.defineProperty(global, 'IntersectionObserver', {
+ writable: true,
+ configurable: true,
+ value: MockIntersectionObserver
+ })
+}
+
+function clearMocks() {
+ Object.defineProperty(global, 'IntersectionObserver', {
+ writable: true,
+ configurable: true,
+ value: null
+ })
+}
diff --git a/components/x-live-blog-wrapper/src/lib/post-tracker.js b/components/x-live-blog-wrapper/src/lib/post-tracker.js
new file mode 100644
index 000000000..188846b22
--- /dev/null
+++ b/components/x-live-blog-wrapper/src/lib/post-tracker.js
@@ -0,0 +1,467 @@
+/**
+ * Callback for when an element enters the user's view port.
+ *
+ * @callback onEntersViewport
+ * @param {{timestamp: string, element: HTMLElement}} event - the callback triggered when a post enters the user's viewport.
+ */
+
+/**
+ * Callback for the view event.
+ *
+ * @callback onRead
+ * @param {{post: object, viewport: object: ?element: HTMLElement, summary: object[]}} event - the view event.
+ */
+
+/**
+ * Callback for the error event.
+ *
+ * @callback onError
+ * @param {Error} event - the error event.
+ */
+
+/**
+ * @typedef ObserverOptions
+ * @type {object}
+ * @property {?HTMLElement} root - The element that is used as the viewport for checking visibility of the target. null referes to browser viewport.
+ * @property {string} rootMargin - Margin around the root. Can have values similar to the CSS margin property, e.g. "10px 20px 30px 40px" (top, right, bottom, left).
+ * @property {number[]} threshold - Either a single number or an array of numbers which indicate at what percentage of the target's visibility the observer's callback should be executed.
+ *
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/IntersectionObserver}
+ */
+
+/**
+ * @typedef PostTrackerConfig
+ * @type {object}
+ * @property {ObserverOptions} options - The options object passed into the IntersectionObserver() constructor let you control the circumstances under which the observer's callback is invoked.
+ * @property {string} query - The query string used to query elements from the DOM.
+ * @property {Boolean} returnVisibleElement - When this is set to true, listeners of the visible event will get the target element id and the element. Defaults to false
+ * @property {number} minMillisecondsToReport - The minimum time in milliseconds a DOM object must be visible on the users view port to report a View event as seen. defaults to 5000
+ * @property {?string} observerUpdateEventString - The DOM event to listen for(on the document level) to update the elements being observed.
+ * @property {?string} liveBlogWrapperQuery - HTMLElement query string for the liveblogwrapper element. Used to get the element (document.querySelector) to listen for DOM events
+ * @property {?HTMLElemet} liveBlogWrapper - liveblogwrapper element
+ * @property {!onEntersViewport} onEntersViewport - Function to call when a Post enters a user's viewport
+ * @property {!onRead} onRead - Function to call when a Post has been in the user's viewport for >= {@param minMillisecondsToReport} || 5000ms
+ * @property {!onError} onError - Function to call when there's an error
+ */
+
+/**
+ * The height and width of the user's viewPort
+ * @typedef {{height: number, width: number}} ViewPort
+ */
+
+/**
+ * @typedef VisibilityData
+ * @type {object}
+ * @property {number} elementHeight - the actual height (in pixels) of the element being observed
+ * @property {number} elementWidth - the actual width (in pixels) of the element being observed
+ * @property {Date} start - the Date time inwhich this specific threshold came into view
+ * @property {number} threshold - the percentage of the element currently visible on the users viewport. 0 -> 1
+ * @property {number} time - the time in milliseconds this intersection was recorded relative to the IntersectionObserver's origin time
+ * @property {string} [type="INTERSECTION_DATA"] - the type of data recorded [INTERSECTION_DATA || TAB_VISIBILIYTY_DATA]
+ * @property {ViewPort} viewPort - the current height and width of the viewport
+ * @property {number} visibleHeight - the currently visibleHeight of the element on the user's viewport
+ * @property {number} visibleWidth - the currently visible width of the element on the user's viewport
+ * @property {number} xBound - The x cartesian coordinate of the left side of the element. Coordinates start from the left side of the screen. 0 = absolute left
+ * @property {number} yBound - The y cartesian coordinate of the top side of the element. Coordinates start from the top of the screen. 0 = absolute top
+ * @property {?number} duration - A computed total number of time this intersection was visible before the next intersection was recorded
+ */
+
+/**
+ * @typedef TabVisibilityData
+ * @type {object}
+ * @property {Boolean} hidden - if the tab is current hidden from the user i.e browser minimised or user on a different tab
+ * @property {Date} timestamp - Datetime in which the visibility data was recorded
+ * @property {number} time - time in milliseconds inwhich the tab visibility data was recorded
+ * @property {string} [type="TAB_VISIBILITY_DATA"] - the type of data recorded [INTERSECTION_DATA || TAB_VISIBILITY_DATA]
+ */
+
+const DATA_TYPES = {
+ intersectionData: 'INTERSECTION_DATA',
+ tabVisibilityDATA: 'TAB_VISIBILITY_DATA'
+}
+
+/**
+ * A class representing an intersection observer tracker.
+ *
+ * Used to track how a user reads through articles on FT pages.
+ *
+ * If no elements are found IntersectionObserverTracker the onError callback is called.
+ */
+export class PostTracker {
+ /**
+ * Create a new Intersection Observer tracker
+ * @param {PostTrackerConfig} config
+ */
+ constructor(config) {
+ /**
+ * Create a new Intersection Observer tracker
+ * @type {PostTrackerConfig} config
+ */
+ this.config = config
+ this.currentlyObservedElements = new Set()
+ this.visibleElements = new Set()
+ /**
+ * live blog wrapper element
+ * @type {HTMLElement}
+ */
+ this.wrapperElement = config.liveBlogWrapper
+ ? config.liveBlogWrapper
+ : document.querySelector(config.liveBlogWrapperQuery)
+ if (config) {
+ this.onEntersViewport = config.onEntersViewport
+ this.onRead = config.onRead
+ this.onError = config.onError
+ }
+ this.defaultOptions = {
+ root: null,
+ rootMargin: '0px',
+ threshold: [0.1, 0.2, 0.5, 0.75, 0.99]
+ }
+ this.setUp()
+ }
+
+ setUp() {
+ if (IntersectionObserver) {
+ if (!this.isValidConfig()) {
+ return
+ }
+
+ // check if the parent wrapper element for posts exist
+ if (!this.wrapperElement) {
+ return
+ }
+
+ if (!this.config.minMillisecondsToReport) {
+ // set minTimeToReport to a default value of 5000
+ this.config.minMillisecondsToReport = 5000
+ }
+
+ this.observer = new IntersectionObserver((entries) => this.manageIntersection(entries), {
+ ...this.defaultOptions,
+ ...this.config.options
+ })
+ if (this.observer) {
+ this.observeElements(this.config.query)
+ }
+
+ document.addEventListener('visibilitychange', (event) => this.handleVisibilityChange(event))
+ window.addEventListener('beforeunload', () => this.handleWindowClose())
+
+ // updates the list of elements currently being observed
+ if (this.config.observerUpdateEventString && this.wrapperElement) {
+ this.wrapperElement.addEventListener(this.config.observerUpdateEventString, () =>
+ this.observeElements(this.config.query)
+ )
+ }
+ } else {
+ this.triggerError(new Error('Unsupported browser.'))
+ }
+ }
+
+ /**
+ * checks if the PostTrackerConfig passed in is valid
+ *
+ * @returns {Boolean}
+ */
+ isValidConfig() {
+ if (!this.config) {
+ // eslint wasn't happy with the spaces in the template literal. Therefore the long line error message
+ let errorMessage =
+ 'PostTrackerConfig is missing. \nUsage example:\nconst tracker = new IntersectionObserverTracker({\noptions: {\nroot: null,\nrootMargin: "0px",\nthreshold: [0.1, 0.2, 0.5, 0.75, 1]\n},\nquery: \'live-blog-post\'})'
+ this.triggerError(new Error(errorMessage))
+ return false
+ }
+
+ if (!this.config.query) {
+ let errorMessage =
+ "PostTrackerConfig.query is missing.\nUsage example:\nconst tracker = new IntersectionObserverTracker({\noptions: {...},\nquery: 'live-blog-post'})"
+ this.triggerError(new Error(errorMessage))
+ return false
+ }
+
+ return true
+ }
+
+ /**
+ * Handles reporting when a DOM element gets into the view
+ * Also reports when a post has been on the viewport for more than 5s
+ *
+ * @param {IntersectionObserverEntry[]} entries
+ */
+ manageIntersection(entries) {
+ entries.forEach((entry) => {
+ if (this.onEntersViewport) {
+ this.reportOnEntersViewport(entry)
+ }
+
+ if (this.onRead) {
+ this.summariseAndReport(entry)
+ }
+ })
+ }
+
+ /**
+ * Adds window visibility data to dataset.visibilityData for all DOM elements if this.visibleElements
+ */
+ handleVisibilityChange(event) {
+ const timestamp = new Date()
+ if (this.visibleElements.size > 0) {
+ this.visibleElements.forEach((element) => {
+ if (element.dataset.visibilityData) {
+ let visibilityData = JSON.parse(element.dataset.visibilityData)
+ if (Array.isArray(visibilityData)) {
+ let data = {
+ type: DATA_TYPES.tabVisibilityDATA,
+ hidden: document.hidden,
+ timestamp,
+ time: event.timeStamp
+ }
+ visibilityData.push(data)
+ element.dataset.visibilityData = JSON.stringify(visibilityData)
+ }
+ }
+ })
+ }
+ }
+
+ /**
+ * Starts observing a list of elements that match query.
+ * Calls the on error callback if no element is found.
+ *
+ * @param {string} query - query string to be used for matching
+ */
+ observeElements(query) {
+ let elements = this.wrapperElement.querySelectorAll(query)
+ if (query && elements.length) {
+ elements.forEach((element) => {
+ if (!this.isElementBeingObserved(element)) {
+ this.observer.observe(element)
+ this.currentlyObservedElements.add(element)
+ }
+ })
+ } else {
+ this.triggerError(new Error('No DOM elements found with the query passed in PostTrackerConfig'))
+ }
+ }
+
+ /**
+ * Disconnects the intersection observer and stops watching for visibility changes
+ * @returns
+ */
+ stopObservation() {
+ if (!this.observer || !this.config.query || !this.wrapperElement) {
+ return
+ }
+ this.observer.disconnect()
+ }
+
+ /**
+ * Stops observing element visibility and cleans up resources
+ */
+ destroy() {
+ this.runFinalReadReport()
+ this.stopObservation()
+ this.currentlyObservedElements = new Set()
+ this.visibleElements = new Set()
+ }
+
+ /**
+ * Calls the onEntersViewport callback only once when the target element of an IntersectionObserverEntry enters the user's viewPort.
+ *
+ * @param {IntersectionObserverEntry} entry
+ */
+ reportOnEntersViewport(entry) {
+ if (entry.isIntersecting && !entry.target.dataset.seen) {
+ let element = entry.target
+ element.dataset.seen = true
+ let eventData = {
+ timestamp: new Date().toISOString()
+ }
+ if (this.config.returnVisibleElement) {
+ eventData = {
+ ...eventData,
+ element
+ }
+ }
+ this.onEntersViewport(eventData)
+ }
+ }
+
+ /**
+ *
+ * @param {IntersectionObserverEntry} entry
+ */
+ summariseAndReport(entry) {
+ // only add summary data when threshold/intersectionRatio is greater than 0
+ if (entry.intersectionRatio > 0) {
+ this.summariseAndStoreViewData(entry)
+ }
+
+ if (!entry.isIntersecting) {
+ this.reportRead(entry.target)
+ }
+
+ this.updateVisibleElements(entry)
+ }
+
+ /**
+ * Gets the summary data from the IntersectionObserverEntry and stores it
+ * in the IntersectionObserverEntry.target.dataset.visibilityData as a
+ * JSON.stringified object (refers to an Array).
+ *
+ * @param {IntersectionObserverEntry} entry
+ */
+ summariseAndStoreViewData(entry) {
+ const element = entry.target
+ const data = {
+ type: DATA_TYPES.intersectionData,
+ threshold: entry.intersectionRatio,
+ xBound: entry.intersectionRect.x, // x coordinate on the screen. left of screen = 0
+ yBound: entry.intersectionRect.y, // y coordinate on the screen. top of screen = 0
+ visibleHeight: entry.intersectionRect.height,
+ visibleWidth: entry.intersectionRect.width,
+ elementHeight: element.clientHeight,
+ elementWidth: element.clientWidth,
+ start: new Date(),
+ time: entry.time,
+ viewport: {
+ height: window.innerHeight,
+ width: window.innerWidth
+ }
+ }
+ if (element.dataset.visibilityData) {
+ let currentData = JSON.parse(element.dataset.visibilityData)
+ currentData.push(data)
+ element.dataset.visibilityData = JSON.stringify(currentData)
+ } else {
+ element.dataset.visibilityData = JSON.stringify([data])
+ }
+ }
+
+ /**
+ * Emits the View data to the consumer
+ *
+ * @param {HTMLElement} element
+ */
+ reportRead(element) {
+ /**
+ * @type {(VisibilityData|TabVisibilityData)[]}
+ */
+ let summary = element.dataset.visibilityData
+ if (summary) {
+ summary = JSON.parse(summary)
+ let viewData = {
+ ...this.processTime(summary),
+ element: this.config.returnVisibleElement ? element : null,
+ post: {
+ height: element.clientHeight,
+ width: element.clientWidth
+ },
+ viewport: {
+ height: window.innerHeight,
+ width: window.innerWidth
+ }
+ }
+ if (viewData.duration >= this.config.minMillisecondsToReport) {
+ this.onRead(viewData)
+ }
+ }
+
+ delete element.dataset.visibilityData
+ }
+
+ /**
+ * Adds or removes a DOM element from the this.visibleElements depending on if
+ * the element is intersecting
+ *
+ * @param {IntersectionObserverEntry} entry
+ */
+ updateVisibleElements(entry) {
+ if (entry.isIntersecting && !this.visibleElements.has(entry.target)) {
+ this.visibleElements.add(entry.target)
+ } else if (this.visibleElements.has(entry.target)) {
+ this.visibleElements.delete(entry.target)
+ }
+ }
+
+ /**
+ * Processes the times for when the DOM element was in view
+ *
+ * @param {(VisibilityData|TabVisibilityData)[]} summary
+ */
+ processTime(summary) {
+ /**
+ * total time visible
+ * time away from tab
+ * time a specific intersection was visible
+ */
+ let aggregatedSummary = []
+ let totalTime = 0
+ const start = summary[0].start
+ let end
+ for (let i = 0; i < summary.length; i++) {
+ const element = summary[i]
+ const next = summary[i + 1]
+ let newElement
+ // calculate duration for total time spent for specific intersection
+ if (element.type === DATA_TYPES.intersectionData) {
+ newElement = {
+ ...element,
+ // if no next element, use window performance to calculate total time of intersection
+ duration: (next ? next.time : performance.now()) - element.time
+ }
+ totalTime += newElement.duration
+ }
+
+ // this also considers situations where the user closes the tab
+ // while it is hidden. The last dataset should be tab_visibility_data with hidden = true
+ if (!next) {
+ end = element.timestamp || new Date().toISOString()
+ }
+
+ aggregatedSummary.push(newElement || element)
+ }
+
+ return {
+ summary: aggregatedSummary,
+ duration: totalTime,
+ start,
+ end
+ }
+ }
+
+ /**
+ * Emits the read event for all visible elements when the user closes the browser window
+ */
+ handleWindowClose() {
+ this.runFinalReport()
+ }
+
+ /**
+ * runs read report for all visible elements
+ */
+ runFinalReadReport() {
+ this.visibleElements.forEach((element) => this.reportRead(element))
+ }
+
+ /**
+ * Calls the onError callback if a function is passed to the
+ * @param {*} error
+ */
+ triggerError(error) {
+ if (this.onError) {
+ this.onError(error)
+ }
+ }
+
+ /**
+ * checks if an element is currently being observed
+ *
+ * @param {HTMLElement} element
+ *
+ * @returns {boolean}
+ */
+ isElementBeingObserved(element) {
+ return this.currentlyObservedElements.has(element)
+ }
+}
diff --git a/components/x-live-blog-wrapper/storybook/index.jsx b/components/x-live-blog-wrapper/storybook/index.jsx
index f6e9ed7c0..9248bdc55 100644
--- a/components/x-live-blog-wrapper/storybook/index.jsx
+++ b/components/x-live-blog-wrapper/storybook/index.jsx
@@ -35,6 +35,13 @@ const Ad = (props) => {
const defaultProps = {
message: 'Test',
+ id: 'live-blog-wrapper',
+ postTrackerConfig: {
+ onEntersViewport: () => {},
+ onRead: () => {},
+ onError: () => {},
+ usePostTracker: true
+ },
posts: [
{
id: 12345,
@@ -62,6 +69,24 @@ const defaultProps = {
publishedDate: '2020-05-13T20:52:28.000Z',
articleUrl: 'https://www.ft.com/content/2b665ec7-a88f-3998-8f39-5371f9c791ed',
showShareButtons: true
+ },
+ {
+ id: 12348,
+ title: 'Title 4',
+ bodyHTML: 'Post 4
',
+ isBreakingNews: false,
+ publishedDate: '2020-05-13T20:52:28.000Z',
+ articleUrl: 'https://www.ft.com/content/2b665ec7-a88f-3998-8f39-5371f9c791ed',
+ showShareButtons: true
+ },
+ {
+ id: 12349,
+ title: 'Title 5',
+ bodyHTML: 'Post 5
',
+ isBreakingNews: false,
+ publishedDate: '2020-05-13T20:52:28.000Z',
+ articleUrl: 'https://www.ft.com/content/2b665ec7-a88f-3998-8f39-5371f9c791ed',
+ showShareButtons: true
}
],
ads: {