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;