'use strict'; /* global LockScreenNotificationBuilder, LazyLoader, mozIntl */ (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.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')]; var formatter = mozIntl._gaia.RelativeDate(navigator.languages, { style: 'short' }); timestamps.forEach((element) => { var date = new Date(element.dataset.timestamp); formatter.formatElement(element, date); }); }; /** @exports LockScreenWindowManager */ exports.LockScreenNotifications = LockScreenNotifications; })(window);