'use strict';
/* global LockScreenNotificationBuilder, LazyLoader */
(function(exports) {
/**
* Manage Notifications in Lockscreen:
* Handle notification changes from Notifications, and
* update Lockscreen visuals accordingly.
*
* @constructor LockScreenNotifications
*/
var LockScreenNotifications = function() {};
LockScreenNotifications.prototype.start =
function lsn_start(lockScreen, container){
this._lockScreen = lockScreen;
this.container = container;
LazyLoader.load(['lockscreen/js/lockscreen_notification_builder.js'])
.then(() => {
this._lockScreenNotificationBuilder =
new LockScreenNotificationBuilder(this.container);
this._lockScreenNotificationBuilder.start();
});
this.arrow =
document.getElementById('lockscreen-notification-arrow');
// The 'scroll' event can't be forwarded via 'window'.
this.container.addEventListener('scroll', this);
this.configs = {
listens: [
'lockscreen-notification-request-highlight',
'lockscreen-notification-notify-highlighted',
'lockscreen-notification-request-clean-highlighted',
'lockscreen-notification-request-activate',
'lockscreen-notification-request-append',
'lockscreen-notification-request-remove',
'lockscreen-notification-request-clear',
'lockscreen-appopened',
'lockscreen-appclosed',
'touchstart',
'visibilitychange',
'scroll'
],
// UX require to cancel the highlighted state after
// idle 3 seconds.
timeoutHighlighted: 3000
};
this.configs.listens.forEach((ename) => {
window.addEventListener(ename, this);
});
this.states = {
// UX require to clean idle and highlighted notification
// after sevaral seconds.
highlightedNotifications: {},
currentHighlighted: null,
currentHighlightedId: null,
arrowVisible: false // container is scrollable
};
};
LockScreenNotifications.prototype.handleEvent =
function lsn_handleEvent(evt) {
var detail = evt.detail || {};
var { id, timestamp, node } = detail;
switch (evt.type) {
case 'lockscreen-notification-request-append':
this.onNotificationAppend(id, node);
break;
case 'lockscreen-notification-request-remove':
var containerEmpty = false;
if (evt.detail && evt.detail.containerEmpty) {
containerEmpty = true;
}
this.onNotificationRemoved(containerEmpty);
break;
case 'lockscreen-notification-request-clear':
this.onNotificationsClear();
break;
case 'lockscreen-notification-request-highlight':
if (!this.notificationOutOfViewport(evt.detail.node)) {
evt.detail.highlighter();
}
break;
case 'lockscreen-notification-notify-highlighted':
this.onNotificationHighlighted(id, timestamp);
break;
case 'lockscreen-notification-request-clean-highlighted':
this.onCleanHighlighted(id, timestamp);
break;
case 'lockscreen-notification-request-activate':
this.onNotificationActivate(evt.detail);
break;
case 'touchstart':
if (!evt.target.classList.contains('notification') &&
!evt.target.classList.contains('button-actionable')) {
this.onNotificationsBlur();
}
break;
case 'lockscreen-appopened':
// When it's visible because of locking, bind the listener.
window.addEventListener('touchstart', this);
break;
case 'lockscreen-appclosed':
// When it's invisible because of unlocking, unbind the listener.
window.removeEventListener('touchstart', this);
break;
case 'scroll':
this.onContainerScrolling();
break;
case 'visibilitychange':
if (!document.hidden) {
// When it's visible, bind the listener.
window.addEventListener('touchstart', this);
this.updateTimestamps();
} else {
// When it's invisible, unbind the listener.
window.removeEventListener('touchstart', this);
}
break;
}
};
LockScreenNotifications.prototype.onContainerScrolling =
function lsn_onContainerScrolling() {
if (this.notificationOutOfViewport(this.states.currentHighlighted)) {
this.onNotificationsBlur();
}
};
LockScreenNotifications.prototype.notificationOutOfViewport =
function lsn_notificationOutOfViewport(node) {
var top, bottom;
var topBoundary, bottomBoundary;
if (node) {
// If we're in HVGA resolution, the too small notification container
// would lead second and following up notifications overlap the boundary
// by several pixels.
var HVGATolerance = 0;
if (this._getWindowInnerDimension().height <= 480) {
HVGATolerance = 1;
}
topBoundary = this.container.getBoundingClientRect().top;
bottomBoundary = this.container.getBoundingClientRect().bottom;
top = node.getBoundingClientRect().top;
bottom = node.getBoundingClientRect().bottom;
if (top < topBoundary ||
bottom > bottomBoundary + HVGATolerance) {
return true;
} else {
return false;
}
}
return false;
};
/**
* Get the notification at the bottom boundary.
*/
LockScreenNotifications.prototype.notificationAtBottomBoundary =
function lsn_notificationAtBottomBoundary() {
var boundaryElement = this.arrow;
var detectingPoint = {
x: window.innerWidth >> 1,
y: boundaryElement.getBoundingClientRect().top +
this.configs.boundaryThresholdBottom
};
var element = document.elementFromPoint(detectingPoint.x, detectingPoint.y);
if (element.classList.contains('notification')) {
return element;
}
return null;
};
/**
* Get the notification at the top boundary.
*/
LockScreenNotifications.prototype.notificationAtTopBoundary =
function lsn_notificationAtBottomBoundary() {
var boundaryElement = this.container;
var detectingPoint = {
x: window.innerWidth >> 1,
y: boundaryElement.getBoundingClientRect().top +
this.configs.boundaryThresholdTop
};
var element = document.elementFromPoint(detectingPoint.x, detectingPoint.y);
if (element.classList.contains('notification')) {
return element;
}
return null;
};
LockScreenNotifications.prototype.onNotificationsBlur =
function lsn_onNotificationsBlur() {
var id = this.states.currentHighlightedId;
var notificationNode = this.states.currentHighlighted;
if (notificationNode &&
notificationNode.classList.contains('actionable')) {
this.removeNotificationHighlight(notificationNode);
delete this.states.highlightedNotifications[id];
this.states.currentHighlighted = null;
this.states.currentHighlightedId = null;
this._tryAddTopMaskByNotification(notificationNode);
}
};
/**
* Record the timestamp when a notification is highlighted.
*/
LockScreenNotifications.prototype.onNotificationHighlighted =
function lsn_onNotificationHighlighted(id, timestamp) {
// Overwrite existing because re-clicking on it is legal.
this.states.highlightedNotifications[id] = timestamp;
this.states.currentHighlightedId = id;
this.states.currentHighlighted = this.container.querySelector(
'[data-notification-id="' + id + '"]');
// because background of an active actionable notification can be clipped
// by the top mask implemented in lockscreen visual refresh 2.0 (bug1023500)
// we need to cancel the mask when the 'top' (in the visible viewport, not
// the whole container) notification is active, by 'top-actionable' class.
// (note: the 'top mask' displays only when the container scrolls to bottom)
// such 'top'-ness is decided by the viewport size of the container:
// if the container can show N visible notifications,
// then the last-Nth notification is that 'top' notification.
// Illustration:
//
// --top------------------ container -------------bottom--
// ======= viewport ========
// ------------------------------=========================
// | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
// | | | | | | 0 | 1 | 2 | 3 |
// ------------------------------=========================
// In this illustration, the ID of the visually top notification in the
// viewport is #5; the number of notifications in container viewport is 4.
// We can select the #5 notification by :nth-last-of-type(4).
var numNotificationInContainerViewport =
this._getNumNotificationInContainerViewport();
if (this.container.querySelector('div.notification:nth-last-of-type(' +
numNotificationInContainerViewport + ')') ===
this.states.currentHighlighted){
this.container.classList.add('top-actionable');
}else{
this.container.classList.remove('top-actionable');
}
};
/**
* Clean the highlighted notification.
*/
LockScreenNotifications.prototype.onCleanHighlighted =
function lsn_onNotificationHighlighted(id, timestamp) {
var prevTimestamp = this.states.highlightedNotifications[id];
if (prevTimestamp &&
timestamp - prevTimestamp > this.configs.timeoutHighlighted) {
var notificationNode = this.container.querySelector(
'[data-notification-id="' + id + '"]');
if (notificationNode &&
notificationNode.classList.contains('actionable')) {
this.removeNotificationHighlight(notificationNode);
delete this.states.highlightedNotifications[id];
this.states.currentHighlighted = null;
this.states.currentHighlightedId = null;
this._tryAddTopMaskByNotification(notificationNode);
}
}
};
LockScreenNotifications.prototype.removeNotificationHighlight =
function lsn_removeNotificationHighlight(node) {
node.classList.remove('actionable');
node.querySelector('.button-actionable').remove();
var oldPrevious = this.container.querySelector(
'.notification.previous-actionable');
if (null !== oldPrevious) {
oldPrevious.classList.remove('previous-actionable');
}
};
/**
* When user clicked on the notification while it's locked.
*/
LockScreenNotifications.prototype.onNotificationActivate =
function lsn_onNotificationActivate(info) {
this._lockScreen._unlockingMessage = {
notificationId: info.notificationId
};
window.dispatchEvent(
new CustomEvent('lockscreen-notification-request-activate-unlock'));
};
/**
* When new notification appended, do the visual change.
* Would receive a ordinary Notificaition node.
*/
LockScreenNotifications.prototype.onNotificationAppend =
function lsn_onNotificationsAppend(id, node) {
var notifSelector = '[data-notification-id="' + id + '"]';
// First we try and find an existing notification with the same id.
// If we have one, we'll replace it. If not, we'll create a new node.
var oldLockScreenNode =
this.container.querySelector(notifSelector);
if (oldLockScreenNode) {
this.container.replaceChild(
node,
oldLockScreenNode
);
}
else {
this.container.insertBefore(
node,
this.container.firstElementChild
);
}
this._lockScreenNotificationBuilder.decorate(node);
this.showColoredMaskBG();
// UX specifies that the container should scroll to top
/* note two things:
* 1. we need to call adjustContainerVisualHints even
* though we're setting scrollTop, since setting sT doesn't
* necessarily invoke onscroll (if the old container is already
* scrolled to top, we might still need to decide to show
* the arrow)
* 2. set scrollTop before calling adjustContainerVisualHints
* since sT = 0 will hide the mask if it's showing,
* and if we call aCVH before setting sT,
* under some circumstances aCVH would decide to show mask,
* only to be negated by st = 0 (waste of energy!).
*/
this.scrollToTop();
// check if lockscreen notifications visual
// hints (masks & arrow) need to show
this.adjustContainerVisualHints();
};
LockScreenNotifications.prototype.onNotificationRemoved =
function lsn_onNotificationRemoved(containerEmpty) {
// if we don't have any notifications,
// use the no-notifications masked background for lockscreen
if (containerEmpty) {
this.hideColoredMaskBG();
}
// check if lockscreen notifications visual
// hints (masks & arrow) need to show
this.adjustContainerVisualHints();
};
LockScreenNotifications.prototype.onNotificationsClear =
function lsn_onNotificationsClear() {
// remove the "have notifications" masked background from lockscreen
this.hideColoredMaskBG();
// check if lockscreen notifications visual
// hints (masks & arrow) need to show
this.adjustContainerVisualHints();
};
/**
* Bind lockscreen onto this module
*
* @this {LockScreenNotifications}
* @memberof LockScreenNotifications
* @param bindLockScreen the lockScreen instance
*/
LockScreenNotifications.prototype.bindLockScreen =
function lsn_bindLockScreen(lockScreen) {
this._lockScreen = lockScreen;
};
/**
* When we have notifications, show bgcolor from wallpaper
* Remove the simple gradient at the same time
*
* @memberof LockScreenNotifications
* @this {LockScreenNotifications}
*/
LockScreenNotifications.prototype.showColoredMaskBG =
function lsn_showColoredMaskBG() {
this._lockScreen.maskedBackground.style.backgroundColor =
this._lockScreen.maskedBackground.dataset.wallpaperColor;
this._lockScreen.maskedBackground.classList.remove('blank');
};
/**
* When we don't have notifications, use the
* simple gradient as lockscreen's masked background
*
* @memberof LockScreenNotifications
* @this {LockScreenNotifications}
*/
LockScreenNotifications.prototype.hideColoredMaskBG =
function lsn_hideColoredMaskBG() {
this._lockScreen.maskedBackground.style.backgroundColor = 'transparent';
this._lockScreen.maskedBackground.classList.add('blank');
};
/**
* We use a smaller notifications container when we have a media player
* widget on the lockscreen. This function collapses the container.
*
* @memberof LockScreenNotifications
* @this {LockScreenNotifications}
*/
LockScreenNotifications.prototype.collapseNotifications =
function lsn_collapseNotifications() {
this._lockScreen.notificationsContainer.classList.add('collapsed');
this._lockScreen.notificationArrow.classList.add('collapsed');
};
/**
* ...and this function expands the container.
*
* @memberof LockScreenNotifications
* @this {LockScreenNotifications}
*/
LockScreenNotifications.prototype.expandNotifications =
function lsn_expandNotifications() {
this._lockScreen.notificationsContainer.classList.remove('collapsed');
this._lockScreen.notificationArrow.classList.remove('collapsed');
};
/**
* adjust the container's visual hints: masks and "more notification" arrow
* @memberof LockScreenNotifications
* @this {LockScreenNotifications}
*/
LockScreenNotifications.prototype.adjustContainerVisualHints =
function lsn_adjustContainerVisualHints() {
var notificationsContainer = this._lockScreen.notificationsContainer;
/* mask:
* the gradient masks only show when:
* - "top" mask: when the user scrolls to bottom (this is a solid mask)
* - "both" mask: when the user scrolls in between (this is a gradient mask)
* (but we need to rule out the situation that
* the container isn't actually scrollable)
*/
if(notificationsContainer.clientHeight ===
notificationsContainer.scrollHeight){
// no mask if the container can't be scrolled
this._setMaskVisibility(false, false);
}else{
if(notificationsContainer.scrollTop +
notificationsContainer.clientHeight ===
notificationsContainer.scrollHeight){
// user scrolls to bottom -> top mask
this._setMaskVisibility(true, false);
}else if(0 === notificationsContainer.scrollTop){
// user scrolls to top -> no mask
this._setMaskVisibility(false, false);
}else{
// anything in between -> "both" mask
this._setMaskVisibility(false, true);
}
}
/*
* arrow:
* The "more notifications" arrow only shows
* when the user is on the top of the container
* and the container is scrollable
*/
this._setArrowVisibility(
0 === notificationsContainer.scrollTop &&
notificationsContainer.clientHeight <
notificationsContainer.scrollHeight
);
};
/**
* Sets the masks' visibility.
*
* @private
* @memberof LockScreenNotifications
* @this {LockScreenNotifications}
* @param top Boolen shows top solid mask or not
* @param both Boolen shows top-and-bottom gradient masks or not
*/
LockScreenNotifications.prototype._setMaskVisibility =
function lsn__setMaskVisibility(top, both) {
var notificationsContainer = this._lockScreen.notificationsContainer;
if(top){
notificationsContainer.classList.add('masked-top');
}else{
notificationsContainer.classList.remove('masked-top');
}
if(both){
notificationsContainer.classList.add('masked-both');
}else{
notificationsContainer.classList.remove('masked-both');
}
};
/**
* Sets the arrow's visibility.
*
* @private
* @memberof LockScreenNotifications
* @this {LockScreenNotifications}
* @param top Boolen shows top solid mask or not
* @param both Boolen shows top-and-bottom gradient masks or not
*/
LockScreenNotifications.prototype._setArrowVisibility =
function lsn__setArrowVisibility(visible) {
var notificationArrow = this._lockScreen.notificationArrow;
if(visible){
notificationArrow.classList.add('visible');
this.states.arrowVisible = true;
}else{
notificationArrow.classList.remove('visible');
this.states.arrowVisible = false;
}
};
/**
* Scrolls the notifications to the top
*/
LockScreenNotifications.prototype.scrollToTop =
function lsn_scrollToTop() {
this._lockScreen.notificationsContainer.scrollTop = 0;
};
LockScreenNotifications.prototype._getWindowInnerDimension =
function lsn_getWindowInnerDimension() {
return {
height: window.innerHeight,
width: window.innerWidth
};
};
/**
* Get the number of notifications visible in the container.
* we have 2 viewable notifications for HVGA lockscreen with music player
* widget, and for larger screen, and the absence of the widget,
* we can allow one, or two, more viewable notifications.
*/
LockScreenNotifications.prototype._getNumNotificationInContainerViewport =
function lsn_getNumNotificationInContainerViewport() {
var numNotificationInContainerViewport = 2;
// monitor > HVGA => allow one more notification
if (this._getWindowInnerDimension().height > 480) {
numNotificationInContainerViewport++;
}
// no music player widget => also allow one more notification
if (!this.container.classList.contains('collapsed')) {
numNotificationInContainerViewport++;
}
return numNotificationInContainerViewport;
};
LockScreenNotifications.prototype._tryAddTopMaskByNotification =
function lsn_tryAddTopMaskByNotification(notificationNode) {
// if the unhighlighted node was the visually top notification of the
// viewport, add the top mask back by removing the top-actionable class.
// (see also: onNotificationHighlighted)
var numNotificationInContainerViewport =
this._getNumNotificationInContainerViewport();
if (this.container.querySelector('div.notification:nth-last-of-type(' +
numNotificationInContainerViewport + ')') === notificationNode){
this.container.classList.remove('top-actionable');
}
};
LockScreenNotifications.prototype.updateTimestamps =
function lsn_updateTimestamps() {
var timestamps = [...document.querySelectorAll('.notification .timestamp')];
timestamps.forEach((element) => {
element.textContent =
this.prettyDate(new Date(element.dataset.timestamp));
});
};
/**
* Display a human-readable relative timestamp.
*/
LockScreenNotifications.prototype.prettyDate =
function lsn_prettyDate(time) {
var date;
if (navigator.mozL10n) {
date = navigator.mozL10n.DateTimeFormat().fromNow(time, true);
} else {
date = time.toLocaleFormat();
}
return date;
};
/** @exports LockScreenWindowManager */
exports.LockScreenNotifications = LockScreenNotifications;
})(window);