main/Notification.js

import Cookies from '../vendor/jscookie';
import SimpleVideo from './classes/SimpleVideo';
import customEvent from '../utils/customEvent';
import saveData from '../utils/saveData';

/**
 * Notification module for handling breaking news / live banners and on site notifications
 *
 * @module Notification
 * @prop {String} selector - DOM selector for notification UI
 * @prop {String} labelSelector - DOM selector for notification label element
 * @prop {String} closeButtonSelector - DOM selector for notification close button
 * @prop {String} headlineSelector - DOM selector for notification headline element
 * @prop {String} linkSelector - DOM selector for notification link element
 * @prop {String} videoSelector - DOM selector for notification video element
 * @prop {String} videoTypeCss - CSS class for signaling notification with video
 * @prop {String} activeCss - CSS class for an active notification
 * @prop {String} hidingCss - CSS class for a notification is hiding
 * @prop {String} headerAdCss - CSS class for the header ad element
 * @prop {String} whiteClass - CSS class for white icon color
 * @prop {String} blackClass - CSS class for black icon color
 * @prop {String} ajaxUrl - Ajax base URL for fetching notification data
 * @prop {String} cookieName - Cookie name storing notification viewing and dismissal data
 * @prop {Array} invertTypes - alert types that requires button SVG inverted
 * @prop {Element} $notifcation - DOM reference to the notification UI
 * @prop {Element} $closeButton - DOM reference to the notification close button
 * @prop {Element} $headline - DOM reference to the notification headline
 * @prop {Element} $label - DOM reference to the notification label
 * @prop {Element} $links - DOM reference to the notification a tags
 * @prop {Object} video - reference to a SimpleVideo
 * @prop {Object} currentItem - Object storing data for the current notification item displayed
 * @prop {String} windowTitle - title of the window document
 * @prop {Integer} POLLING_TIMEOUT - Timeout for continuously polling for notifications in ms
 * @prop {Integer} REFETCH_TIMEOUT - Timeout for requesting notifications upon item dimissal in ms
 * @prop {Integer} INITIAL_TIMEOUT - Timeout for initial request of notifications in ms
 */
const Notification = {
	selector: '.l-notification',
	labelSelector: '.l-notification__label',
	closeButtonSelector: '.l-notification__button',
	headlineSelector: '.l-notification__headline',
	videoSelector: '.l-notification__video',
	linkSelector: '.l-notification__link',
	videoTypeCss: 'l-notification--video',
	activeCss: 'l-notification--active',
	hidingCss: 'l-notification--hiding',
	headerAdSelector: '.l-headerAd__container',
	whiteClass: 'c-icon--white',
	blackClass: 'c-icon--black',
	ajaxUrl: '/gnca-ajax-redesign/notification/',
	cookieName: 'gn-notice',
	invertTypes: ['breaking', 'special'],
	$notification: false,
	$closeButton: false,
	$headline: false,
	$label: false,
	$links: false,
	video: false,
	currentItem: false,
	schedule: false,
	windowTitle: '',
	POLLING_TIMEOUT: 180000, // 3 minutes
	REFETCH_TIMEOUT: 10000, // 10 seconds
	INITIAL_TIMEOUT: 10000, // 10 seconds

	/**
	 * Set up event handler notification interactions
	 * and schedule request for getting notifications
	 *
	 * @method init
	 */
	init() {
		// Get user region from global variable
		// Get national notification for homepage, local for other pages

		/* global gnca_settings */
		const settings = gnca_settings.notification_settings;
		const region = settings && settings.region ? settings.region : 'gnca-national';

		// Set up ajax URL for polling notification items.
		const param = JSON.stringify({
			user_region: region,
		});
		this.ajaxUrl = `${this.ajaxUrl}${param}`;

		// Remember window tab title
		this.windowTitle = window.document.title;

		// Cache html elements
		this.$notification = document.querySelector( this.selector );

		// Bail if there's no notification element on the page
		if ( ! this.$notification ) {
			return;
		}

		this.$closeButton = document.querySelector( this.closeButtonSelector );
		this.$headline = this.$notification.querySelector( this.headlineSelector );
		this.$label = this.$notification.querySelector( this.labelSelector );
		this.$links = this.$notification.querySelectorAll( this.linkSelector );

		// Handler for close button click
		this.$closeButton.addEventListener( 'click', () => {
			this.dismissItem();
			this.updateWindowTab();
		});

		this.setup();
	},

	/**
	 * Setup event listener to trigger initial notification request
	 * when sticky ad closes.
	 *
	 * @method setup
	 */
	setup() {
		// Schedule an initial request by default in case the ad never sticks.
		this.scheduleRequest( this.INITIAL_TIMEOUT );

		// When sticky detected, clear scheduled request.
		window.addEventListener( customEvent.STICKY_ON, ( event ) => {
			if ( ! this.currentItem && this.headerAdSelector === event.detail.selector ) {
				clearTimeout( this.schedule );
			}
		});

		// Request when ad is detached
		window.addEventListener( customEvent.STICKY_OFF, ( event ) => {
			if ( ! this.currentItem && this.headerAdSelector === event.detail.selector ) {
				this.scheduleRequest( 0 );
			}
		});
	},

	/**
	 * Schedule a notifications request in timeout milliseconds
	 *
	 * @method scheduleRequest
	 * @param {int} timeout - timeout in milliseconds
	 */
	scheduleRequest( timeout ) {
		if ( this.schedule ) {
			clearTimeout( this.schedule );
		}

		this.schedule = setTimeout( () => {
			this.request();
		}, timeout );
	},

	/**
	 * Ajax request for getting notification items
	 *
	 * @method request
	 */
	request() {
		fetch( this.ajaxUrl )
			.then( response => response.text() )
			.then( ( content ) => {
				this.handleResponse( content );
			});
	},

	/**
	 * Handle returned notification items from ajax call
	 *
	 * @method handleResponse
	 * @param {String} result - JSON parsable string
	 */
	handleResponse( result ) {
		const items = JSON.parse( result );
		if ( items.length > 0 ) {
			// Get user's viewing history of notifications to check for item freshness
			const records = this.getHistory();
			for ( let i = 0; i < items.length; i += 1 ) {
				const item = items[i];
				const itemRecord = this.getItemHistory( item.id );
				item.freshness = this.getFreshness( item, records, itemRecord );

				// Display fresh item
				if ( this.isQualified( item ) ) {
					this.save( 'view', item, records, itemRecord );

					// If a current item is currently show, close ite before showing the new item
					// otherwise, display ite right away
					if ( this.currentItem ) {
						this.resetCurrentItem();
						this.hideAndShow( item );
					} else {
						this.populateItem( item );
						this.show();
					}

					this.track( 'display' );

					// If freshness > 0, then item is brand new and window tab
					// show be updated to reflect so with an "(1)" indicator
					if ( item.freshness > 0 ) {
						this.updateWindowTab( 1 );
					}

					break;
				}
			}
		}

		// schedule next poll
		this.scheduleRequest( this.POLLING_TIMEOUT );
	},

	/*
	 * Populate notification UI with label, headline, link, and css class for alert type styling
	 *
	 * @method populateItem
	 * @param {Object} item - data used for populating the notification UI
	 */
	populateItem( item ) {
		// IE doesn't support classList for SVG.
		if ( this.$notification.dataset.type ) {
			this.$notification.classList.remove( `l-notification--${this.$notification.dataset.type}` );
		}

		this.$notification.classList.add( `l-notification--${item.type}` );
		this.$notification.dataset.type = item.type;
		this.$notification.dataset.freshness = item.freshness;
		this.$headline.textContent = item.headline;
		this.$label.textContent = item.label;

		if ( this.$links ) {
			[].forEach.call( this.$links, ( $elem ) => {
				$elem.setAttribute( 'href', item.url );
				if ( $elem.classList.contains( this.videoSelector.replace( '.', '' ) ) ) {
					$elem.setAttribute( 'title', 'Click to view article with live video' );
				}
			});
		}

		// If a live stream URL is provided and when viewing on desktop,
		// auto play it muted
		if ( item.liveStream && false === saveData() ) {
			this.$notification.classList.add( this.videoTypeCss );
			this.video = new SimpleVideo({
				selector: this.videoSelector,
				source: item.liveStream,
				type: 'application/x-mpegURL',
				autoPlay: true,
				poster: item.image,
			});
			this.video.init();

			window.addEventListener( 'simpleVideoAPIReady', () => {
				setTimeout( () => {
					this.disableVideoControls();
				}, 4000 );
			});
		} else {
			this.$notification.classList.remove( this.videoTypeCss );
		}

		this.currentItem = item;
	},

	/**
	 * Resets current item. If item has a video, stop playback.
	 *
	 * @method resetCurrentItem
	 */
	resetCurrentItem() {
		// If current item has a video in place, remove it
		if ( this.video ) {
			this.video.remove();
			this.video = false;
		}
		this.currentItem = false;
	},

	/*
	 * Dismiss current item and make a new request for new notifications
	 *
	 * @method dismissItem
	 */
	dismissItem() {
		// Remember the item dismissed to ensure user receives fresh items
		this.save( 'dismiss', this.currentItem );

		this.resetCurrentItem();

		// Dismisss current item.
		this.hide();

		// Request new item.
		this.scheduleRequest( this.REFETCH_TIMEOUT );
	},

	/*
	 * Update window tab title to add new notification indicator
	 *
	 * @method updateWindowTab
	 * @param {int} num - when num is not defined, show original title
	 */
	updateWindowTab( num ) {
		if ( num ) {
			window.document.title = `(${num}) ${this.windowTitle}`;
		} else {
			window.document.title = this.windowTitle;
		}
	},

	/*
	 * Get a freshness score of specified item based on viewing history
	 * of notifications
	 *
	 * @method getFreshness
	 * @param {Object} item - target item to check
	 * @param {Object} records - Object indicating array of notifications viewed and dismissed
	 * @param {Object} itemRecord - Object indicating viewed and dismissed headline of a post
	 * @return {int} result - freshness score of the item
	 * 	1  = brand new fresh item, not viewed nor dismissed
	 * 	0  = sort of fresh item, item's been viewed but not dismissed
	 *       (only applies to persistent items)
	 *  -1 = old item, persistent item dismissed, or non-persistent item viewed
	 */
	getFreshness( item, records, itemRecord ) {
		let result = 1;

		// Check item against current item
		if ( this.currentItem
			&& item.id === this.currentItem.id
			&& item.headline === this.currentItem.headline ) {
			result = - 1;
			return result;
		}

		// Check against viewing record
		if ( item.priority > 0 ) {
			// Persistent items come from curatables is not associated with any alertIds
			// Thus freshness depends on both the postId and headline
			if ( records.dismiss.indexOf( item.id ) >= 0
				&& itemRecord.dismiss === item.headline ) {
				// Item with the same headline and post id has been dismissed
				result = - 1;
			} else if ( records.view.indexOf( item.id ) >= 0 && itemRecord.view === item.headline ) {
				// Item with the same headline and post id has been viewed
				result = 0;
			}
		} else if ( records.view.indexOf( item.alertId ) >= 0 ) {
			// For non-persistent item, simply checking the notification alert id would suffice
			// since each is associated with an unique alert id
			result = - 1;
		}

		return result;
	},

	/*
	 * Check if item is qualified to be notified
	 *
	 * @method isQualified
	 * @param {Object} item - new item
	 */
	isQualified( item ) {
		let result = false;

		/* global gnca_settings */
		if ( item.freshness < 0 ) {
			// item is old, not qualifed for notification
			result = false;
		} else if ( gnca_settings.content_id === item.id ) {
			// user currently viewing the same post, do not notify
			result = false;
		} else if ( ! this.currentItem ) {
			// there is no existing item, go ahead and notify new item
			result = true;
		} else if ( item.priority >= this.currentItem.priority
			&& item.freshness >= this.currentItem.freshness ) {
			// only notify an item that is of higher or same priority as current
			// and when item is as fresh or fresher than current item
			result = true;
		}

		return result;
	},

	/*
	 * Get user's viewing / dismissal history of notifications
	 *
	 * @method getHistory
	 * @return {Object} Object with arrays of viewed ids and dismissed ids
	 */
	getHistory() {
		const cookieValue = Cookies.get( this.cookieName );
		const match = new RegExp( 'view=(.*)\\|dismiss=(.*)' ).exec( cookieValue || '' );
		let viewedIds = '';
		let dismissedIds = '';

		if ( match && match.length > 2 ) {
			[, viewedIds, dismissedIds] = match;
		}

		return {
			view: viewedIds ? viewedIds.split( ',' ) : [],
			dismiss: dismissedIds ? dismissedIds.split( ',' ) : [],
		};
	},

	/*
	 * Get viewing and dismissal history of specific item
	 *
	 * @method getItemHistory
	 * @param {String} id - post id
	 * @return {Object} object of previously viewed and dimissed headlines
	 */
	getItemHistory( id ) {
		const cookieValue = Cookies.get( `${this.cookieName}-${id}` );
		const match = new RegExp( 'view=(.*)\\|dismiss=(.*)' ).exec( cookieValue || '' );
		let viewedHeadline = '';
		let dismissedHeadline = '';

		if ( match && match.length > 2 ) {
			[, viewedHeadline, dismissedHeadline] = match;
		}

		return {
			view: viewedHeadline || '',
			dismiss: dismissedHeadline || '',
		};
	},

	/*
	 * Save viewing / dimissal history of a notification item
	 *
	 * @method save
	 * @param {String} mode - 'dismiss' or 'view'
	 * @param {Object} item - notification item data
	 * @param {Object} retrievedRecords - optional. When undefined, method retrieves the history
	 * @param {Object} retrievedItemRecord - optional. When undefined, method retrieves the history
	 */
	save( mode, item, retrievedRecords, retrievedItemRecord ) {
		const records = retrievedRecords || this.getHistory();
		const itemRecord = retrievedItemRecord || this.getItemHistory( item.id );
		const id = item.alertId ? item.alertId : item.id;

		if ( records[mode].indexOf( id ) < 0 ) {
			records[mode].unshift( id );
		}

		let cookieValue = `view=${records.view.join( ',' )}|dismiss=${records.dismiss.join( ',' )}`;
		Cookies.set( this.cookieName, cookieValue, { expires: 1, path: '/' });

		// Remember headline for persistent ( priority > 0 ) items for freshness check.
		if ( item.priority > 0 ) {
			itemRecord[mode] = item.headline;
			cookieValue = `view=${itemRecord.view}|dismiss=${itemRecord.dismiss}`;
			Cookies.set( `${this.cookieName}-${item.id}`, cookieValue, { expires: 1, path: '/' });
		}
	},

	/*
	 * Animate in notification item
	 * @method show
	 */
	show() {
		this.$notification.classList.add( this.activeCss );
		this.$notification.classList.remove( this.hidingCss );
		this.broadcast( customEvent.STICKY_ON );
	},

	/*
	 * Animate out notification item
	 * @method hide
	 */
	hide() {
		this.$closeButton.setAttribute( 'disabled', '' );
		this.$notification.classList.remove( this.activeCss );
		this.$notification.classList.add( this.hidingCss );
		setTimeout( () => {
			this.$notification.classList.remove( this.hidingCss );
			this.$closeButton.removeAttribute( 'disabled' );
			this.broadcast( customEvent.STICKY_OFF );
		}, 500 );
	},

	/*
	 * Hide current item and display new item
	 * @method hideAndShow
	 * @param {Object} item - optional. When defined, populate notification UI
	 *                        with data in item Object
	 */
	hideAndShow( item ) {
		this.$notification.classList.remove( this.activeCss );
		this.$notification.classList.add( this.hidingCss );
		setTimeout( () => {
			if ( item ) {
				this.populateItem( item );
			}
			this.show();
		}, 500 );
	},

	/*
	 * Analytics call for tracking notification actions.
	 * @method track
	 * @param {string} eventName - event name to track.
	 */
	track( eventName ) {
		/* global gn_analytics */
		/* eslint-disable camelcase */
		if ( 'undefined' !== typeof ( gn_analytics ) ) {
			const data = {};
			data[`notification.${eventName}`] = '1';
			gn_analytics.Analytics.track(['adobe', 'ga'], {
				action: `notification | ${eventName}`,
				data,
				target: this.$notification,
			});
		}
		/* eslint-enable camelcase */
	},

	/**
	 * Fire custom event to window object
	 * @method broadcast
	 * @param event - name of custom event
	 */
	broadcast( event ) {
		customEvent.fire( window, event, {
			target: this.$notification,
			selector: this.selector,
		});
	},

	/**
	 * Disable video keyboard controls once the video renders
	 * Users can't pause/play/turn on volume inside notification
	 * so they have to click it to get to the article with the video
	 * @method disableVideoControls
	 */
	disableVideoControls() {
		const videoContainer = document.querySelector( this.videoSelector );
		if ( ! videoContainer ) {
			return;
		}
		const videoContainerInner = videoContainer.querySelector( 'video-js' );

		if ( ! videoContainerInner ) {
			return;
		}

		// Set 'video-js' container to be non-focusable and non-readable to screenreader
		videoContainerInner.setAttribute( 'tabindex', - 1 );
		videoContainerInner.setAttribute( 'aria-hidden', true );
		videoContainerInner.setAttribute( 'aria-disabled', true );

		// Select all items in the live video markup
		// then add tabindex -1 so user can't tab on them
		const videoControls = videoContainerInner.querySelectorAll( '*' );
		if ( 0 < videoControls.length ) {
			Object.keys( videoControls ).forEach( ( key ) => {
				const selector = videoControls[key];
				selector.setAttribute( 'disabled', true );
				selector.setAttribute( 'role', 'none' );
				selector.setAttribute( 'tabindex', - 1 );
				selector.setAttribute( 'aria-hidden', true );
				selector.setAttribute( 'aria-disabled', true );
			});
		}
	},
};

export default Notification;