/* global LazyLoader, MediaPlaybackWidget, Service, SettingsListener, SettingsURL, toneUpgrader, mozIntl */ 'use strict'; var NotificationScreen = { name: 'NotificationScreen', TOASTER_TIMEOUT: 3500, TRANSITION_FRACTION: 0.30, TRANSITION_DURATION: 200, SWIPE_INERTIA: 100, TAP_THRESHOLD: 10, SCROLL_THRESHOLD: 10, CLEAR_DELAY: 80, _notification: null, _containerWidth: null, _touchStartX: 0, _touchStartY: 0, _touchPosX: 0, _touching: false, _isTap: false, _toasterTimeout: null, lockscreenPreview: true, silent: false, vibrates: true, isResending: false, resendReceived: 0, resendExpecting: 0, /* These applications' notifications will be added in the "priority" * notification group */ PRIORITY_APPLICATIONS: [ window.location.origin.replace('system.', 'network-alerts.') + '/manifest.webapp' ], /* These applications' notifications will not be notified, it means we won't * have: * - the banner * - the vibration * - the sound */ SILENT_APPLICATIONS: [ window.location.origin.replace('system.', 'network-alerts.') + '/manifest.webapp' ], getLockScreenContainer: function ns_getLockScreenContainer() { // XXX: Bug 1057198 add this as a workaround before we truly // make LockScreen as an app. var lw = Service.query('LockScreenWindowManager.getInstance'); if (lw && lw.getNotificationContainer()) { return lw.getNotificationContainer(); } else { return null; } }, start: function() { window.addEventListener('mozChromeNotificationEvent', this); this.notificationsContainer = document.getElementById('notifications-container'); this.container = document.getElementById('desktop-notifications-container'); this.lockScreenContainer = this.getLockScreenContainer(); this.toaster = document.getElementById('notification-toaster'); this.ambientIndicator = document.getElementById('ambient-indicator'); this.toasterIcon = document.getElementById('toaster-icon'); this.toasterTitle = document.getElementById('toaster-title'); this.toasterDetail = document.getElementById('toaster-detail'); this.clearAllButton = document.getElementById('notification-clear'); ['tap', 'touchstart', 'touchmove', 'touchend', 'touchcancel', 'wheel']. forEach(function(evt) { this.container.addEventListener(evt, this); this.toaster.addEventListener(evt, this); }, this); this.clearAllButton.addEventListener('click', this.clearAll.bind(this)); // will hold the count of external contributors to the notification // screen this.externalNotificationsCount = 0; this.unreadNotifications = []; window.addEventListener('utilitytrayshow', this); // Since UI expect there is a slight delay for the opened notification. window.addEventListener('lockscreen-appclosed', this); window.addEventListener('visibilitychange', this); window.addEventListener('ftuopen', this); window.addEventListener('ftudone', this); window.addEventListener('desktop-notification-resend', this); this._sound = 'style/notifications/ringtones/notifier_firefox.opus'; // set up the media playback widget, but only if |MediaPlaybackWidget| is // defined (we don't define it in tests) LazyLoader.load(['js/media_playback.js']).then(function() { this.mediaPlaybackWidget = new MediaPlaybackWidget( document.getElementById('media-playback-container'), {nowPlayingAction: 'openapp'}); }.bind(this))['catch'](function(err) { console.error(err); }); var self = this; SettingsListener.observe('notification.ringtone', '', function(value) { LazyLoader.load(['shared/js/settings_url.js']).then(function() { if (!self.ringtoneURL) { self.ringtoneURL = new SettingsURL(); } self._sound = self.ringtoneURL.set(value); })['catch'](function(err) { console.error(err); }); }); // We have new default ringtones in 2.0, so check if the version is upgraded // then execute the necessary migration. if (Service.query('justUpgraded')) { LazyLoader.load('js/tone_upgrader.js').then(function() { toneUpgrader.perform('alerttone'); })['catch'](function(err) { console.error(err); }); } Service.register('clearAll', this); Service.register('addUnreadNotification', this); Service.register('removeUnreadNotification', this); this.resendStoredNotifications(); }, handleEvent: function ns_handleEvent(evt) { switch (evt.type) { case 'mozChromeNotificationEvent': var detail = evt.detail; switch (detail.type) { case 'desktop-notification': this.addNotification(detail); if (this.isResending) { this.resendReceived++; this.isResending = (this.resendReceived < this.resendExpecting); } break; case 'desktop-notification-close': this.removeNotification(detail.id); break; } break; case 'tap': var target = evt.target; this.tap(target); break; case 'touchstart': this.touchstart(evt); break; case 'touchmove': this.touchmove(evt); break; case 'touchend': this.touchend(evt); break; case 'touchcancel': this.touchcancel(evt); break; case 'wheel': this.wheel(evt); break; case 'utilitytrayshow': this.updateTimestamps(); this.hideNotificationIndicator(); break; case 'visibilitychange': //update timestamps in lockscreen notifications if (!document.hidden) { this.updateTimestamps(); } break; case 'ftuopen': this.toaster.removeEventListener('tap', this); break; case 'ftudone': this.toaster.addEventListener('tap', this); this.updateNotificationIndicator(); break; case 'desktop-notification-resend': this.resendExpecting = evt.detail.number; if (this.resendExpecting) { this.isResending = true; } break; case 'lockscreen-appclosed': // UX require to delay to clear notifications from // LockScreen for the actionable LockScreen notifications. setTimeout((function() { this.clearLockScreen(); }).bind(this), 400); break; } }, cancelSwipe: function ns_cancelSwipe() { var notification = this._notification; this._notification = null; // If the notification has been moved, animate it back to its original // position. if (this._touchPosX) { notification.addEventListener('transitionend', function trListener() { notification.removeEventListener('transitionend', trListener); notification.classList.remove('snapback'); }); notification.classList.add('snapback'); } notification.style.transform = ''; }, // Swipe handling touchstart: function ns_touchstart(evt) { if (evt.touches.length !== 1) { if (this._touching) { this._touching = false; this.cancelSwipe(); } return; } var target = evt.touches[0].target; if (!target.dataset.notificationId) { return; } this._notification = target; this._containerWidth = this.container.clientWidth; this._touchStartX = evt.touches[0].pageX; this._touchStartY = evt.touches[0].pageY; this._touchPosX = 0; this._touchStartTime = evt.timeStamp; this._touching = true; this._isTap = true; }, touchmove: function ns_touchmove(evt) { if (!this._touching) { return; } var touchDiffY = evt.touches[0].pageY - this._touchStartY; // The notification being touched is the toast if (this._notification.classList.contains('displayed')) { if (touchDiffY < 0) { this.closeToast(); } return; } // Otherwise, we're swiping on a notification, which should only be allowed // when the utility tray is fully open. if (evt.touches.length !== 1 || !Service.query('UtilityTray.shown') || (this._isTap && Math.abs(touchDiffY) >= this.SCROLL_THRESHOLD)) { this._touching = false; this.cancelSwipe(); return; } this._touchPosX = evt.touches[0].pageX - this._touchStartX; if (Math.abs(this._touchPosX) >= this.TAP_THRESHOLD) { this._isTap = false; this.notificationsContainer.style.overflow = 'hidden'; } if (!this._isTap) { evt.preventDefault(); evt.stopPropagation(); // Prevent this event from bubbling up to the tray. this._notification.style.transform = 'translateX(' + this._touchPosX + 'px)'; } }, touchend: function ns_touchend(evt) { if (!this._touching) { return; } evt.preventDefault(); this._touching = false; this.notificationsContainer.style.overflow = ''; if (this._isTap) { var event = new CustomEvent('tap', { bubbles: true, cancelable: true }); this._notification.dispatchEvent(event); this._notification = null; return; } // Taking speed into account to detect swipes var deltaT = evt.timeStamp - this._touchStartTime; var speed = this._touchPosX / deltaT; var inertia = speed * this.SWIPE_INERTIA; var finalDelta = Math.abs(this._touchPosX + inertia); if (finalDelta > this._containerWidth * this.TRANSITION_FRACTION) { if (this._touchPosX < 0) { this._notification.classList.add('left'); } var durationLeft = (1 - (finalDelta / this._containerWidth)) * this.TRANSITION_DURATION; this.swipeCloseNotification(durationLeft); } else { this.cancelSwipe(); } }, touchcancel: function ns_touchcancel(evt) { if (this._touching) { evt.preventDefault(); this._touching = false; this.cancelSwipe(); } }, wheel: function ns_wheel(evt) { if (evt.deltaMode === evt.DOM_DELTA_PAGE && evt.deltaX) { this._notification = evt.target; this.swipeCloseNotification(); } }, tap: function ns_tap(node) { var notificationId = node.dataset.notificationId; var notificationNode = this.container.querySelector( '[data-notification-id="' + notificationId + '"]'); var event = document.createEvent('CustomEvent'); event.initCustomEvent('mozContentNotificationEvent', true, true, { type: 'desktop-notification-click', id: notificationId }); window.dispatchEvent(event); window.dispatchEvent(new CustomEvent('notification-clicked', { detail: { id: notificationId } })); // Desktop notifications are removed when they are clicked (see bug 890440) if (notificationNode && notificationNode.dataset.type === 'desktop-notification' && notificationNode.dataset.obsoleteAPI === 'true') { this.closeNotification(notificationNode); } if (node == this.toaster) { this.closeToast(); } else { Service.request('UtilityTray:hide'); } }, updateTimestamps: function ns_updateTimestamps() { var timestamps = [...document.querySelectorAll('.timestamp')]; var formatter = mozIntl._gaia.RelativeDate(navigator.languages, { style: 'short' }); timestamps.forEach(timestamp => { formatter.formatElement( timestamp, new Date(timestamp.dataset.timestamp)); }); }, updateToaster: function ns_updateToaster(detail, type, dir) { if (detail.icon) { this.toasterIcon.src = detail.icon; this.toasterIcon.hidden = false; } else { this.toasterIcon.hidden = true; } this.toaster.dataset.notificationId = detail.id; this.toaster.dataset.type = type; this.toaster.lang = detail.lang; this.toasterTitle.textContent = detail.title; this.toasterTitle.dir = dir; this.toasterDetail.textContent = detail.text; this.toasterDetail.dir = dir; }, addNotification: function ns_addNotification(detail) { // LockScreen window may not opened while this singleton got initialized. this.lockScreenContainer = this.getLockScreenContainer(); var manifestURL = detail.manifestURL || ''; var behavior = detail.mozbehavior || {}; var isPriorityNotification = this.PRIORITY_APPLICATIONS.indexOf(manifestURL) !== -1; var dir = detail.dir === 'ltr' || detail.dir === 'rtl' ? detail.dir : 'auto'; var notificationContainer = (isPriorityNotification) ? this.container.querySelector('.priority-notifications') : this.container.querySelector('.other-notifications'); // We need to animate the ambient indicator when the toast // timesout, so we skip updating it here, by passing a skip bool this.addUnreadNotification(detail.id, true); var notificationNode = document.createElement('div'); notificationNode.classList.add('notification'); notificationNode.setAttribute('role', 'link'); notificationNode.setAttribute('tabindex', 0); notificationNode.dataset.notificationId = detail.id; notificationNode.dataset.noClear = behavior.noclear ? 'true' : 'false'; notificationNode.lang = detail.lang; notificationNode.dataset.obsoleteAPI = 'false'; if (typeof detail.id === 'string' && detail.id.indexOf('app-notif-') === 0) { notificationNode.dataset.obsoleteAPI = 'true'; } var type = detail.type || 'desktop-notification'; notificationNode.dataset.type = type; notificationNode.dataset.manifestURL = manifestURL; var icon; if (detail.icon) { icon = document.createElement('img'); icon.src = detail.icon; icon.setAttribute('role', 'presentation'); notificationNode.appendChild(icon); } var titleContainer = document.createElement('div'); titleContainer.classList.add('title-container'); var title = document.createElement('div'); title.classList.add('title'); title.textContent = detail.title; title.dir = dir; titleContainer.appendChild(title); var time = document.createElement('span'); var timestamp = detail.timestamp ? new Date(detail.timestamp) : new Date(); time.classList.add('timestamp'); time.dataset.timestamp = timestamp; var formatter = mozIntl._gaia.RelativeDate(navigator.languages, { style: 'short' }); formatter.formatElement(time, timestamp); titleContainer.appendChild(time); notificationNode.appendChild(titleContainer); var message = document.createElement('div'); message.classList.add('detail'); var messageContent = document.createElement('div'); messageContent.classList.add('detail-content'); messageContent.textContent = detail.text; messageContent.dir = dir; message.appendChild(messageContent); notificationNode.appendChild(message); var notifSelector = '[data-notification-id="' + detail.id + '"]'; var oldNotif = notificationContainer.querySelector(notifSelector); if (oldNotif) { // The whole node cannot be replaced because CSS animations are re-started oldNotif.replaceChild(titleContainer, oldNotif.querySelector('.title-container')); oldNotif.replaceChild(message, oldNotif.querySelector('.detail')); var oldIcon = oldNotif.querySelector('img'); if (icon) { oldIcon ? oldIcon.src = icon.src : oldNotif.insertBefore(icon, oldNotif.firstChild); } else if (oldIcon) { oldNotif.removeChild(oldIcon); } // but we still need to update type and lang. oldNotif.dataset.type = type; oldNotif.lang = detail.lang; notificationNode = oldNotif; } else { notificationContainer.insertBefore(notificationNode, notificationContainer.firstElementChild); } var event = document.createEvent('CustomEvent'); event.initCustomEvent('mozContentNotificationEvent', true, true, { type: 'desktop-notification-show', id: detail.id }); window.dispatchEvent(event); // We turn the screen on if needed in order to let // the user see the notification toaster unless we're on a call if (!Service.query('screenEnabled') && !behavior.noscreen && !Service.query('onCall') && (!Service.query('locked') || this.lockscreenPreview)) { Service.request('turnScreenOn'); } var notify = !('noNotify' in detail) && // don't notify for network-alerts notifications (this.SILENT_APPLICATIONS.indexOf(manifestURL) === -1); // Notification toaster if (notify) { this.updateToaster(detail, type, dir); if (this.lockscreenPreview || !window.Service.query('locked')) { this.toaster.classList.add('displayed'); if (this._toasterTimeout) { clearTimeout(this._toasterTimeout); } this._toasterTimeout = setTimeout((function() { this.closeToast(); this._toasterTimeout = null; }).bind(this), this.TOASTER_TIMEOUT); } } // Adding it to the lockscreen if locked and the privacy setting // does not prevent it. if (Service.query('locked') && this.lockscreenPreview) { this.addLockScreenNotification(detail.id, notificationNode.cloneNode(true)); } if (notify && !this.isResending && (!Service.query('locked') || this.lockscreenPreview)) { if (!this.silent) { var ringtonePlayer = new Audio(); var telephony = window.navigator.mozTelephony; ringtonePlayer.src = behavior.soundFile || this._sound; if (telephony && telephony.active) { ringtonePlayer.mozAudioChannelType = 'telephony'; ringtonePlayer.volume = 0.3; } else { ringtonePlayer.mozAudioChannelType = 'notification'; } ringtonePlayer.play(); window.setTimeout(function smsRingtoneEnder() { ringtonePlayer.pause(); ringtonePlayer.removeAttribute('src'); ringtonePlayer.load(); }, 4000); } if (this.vibrates) { var pattern = [200, 200, 200]; if (behavior.vibrationPattern && behavior.vibrationPattern.length && behavior.vibrationPattern[0] > 0) { pattern = behavior.vibrationPattern; } // bug 1030310: disable vibration for the email app when asleep // bug 1050023: disable vibration for downloads when asleep var vibrateWhenAsleep = type.indexOf('download-notification-downloading') === -1 && manifestURL.indexOf('email.gaiamobile.org') === -1; if (vibrateWhenAsleep || !document.hidden) { navigator.vibrate(pattern); } } } // must be at least one notification now this.clearAllButton.disabled = false; return notificationNode; }, /** * Give a notification node and add it to LockScreen via event. */ addLockScreenNotification: function ns_addLockScreenNotification(id, node) { window.dispatchEvent( new window.CustomEvent('lockscreen-notification-request-append', { detail: { id: id, node: node } })); }, swipeCloseNotification: function ns_swipeCloseNotification(duration) { var notification = this._notification; this._notification = null; var toaster = this.toaster; var self = this; notification.addEventListener('transitionend', function trListener() { notification.removeEventListener('transitionend', trListener); self.closeNotification(notification); if (notification != toaster) { return; } // Putting back the toaster in a clean state for the next notification toaster.style.display = 'none'; setTimeout(function nextLoop() { toaster.style.MozTransition = ''; toaster.style.MozTransform = ''; toaster.classList.remove('displayed'); toaster.classList.remove('disappearing'); setTimeout(function nextLoop() { toaster.style.display = 'block'; }); }); }); notification.classList.add('disappearing'); duration = duration || this.TRANSITION_DURATION; notification.style.transitionDuration = duration + 'ms'; notification.style.transform = ''; }, addUnreadNotification: function ns_addUnreadNotification(id, skipUpdate) { if (Service.query('UtilityTray.shown')) { return; } this.unreadNotifications.push(id); if (!skipUpdate) { this.updateNotificationIndicator(); } }, removeUnreadNotification: function ns_removeUnreadNotification(id) { var notifIndex = this.unreadNotifications.indexOf(id); if (notifIndex > -1) { this.unreadNotifications.splice(notifIndex, 1); } this.updateNotificationIndicator(); }, hideNotificationIndicator: function ns_hideNotificationIndicator() { if (this.unreadNotifications.length > 0) { this.unreadNotifications = []; } this.updateNotificationIndicator(); }, updateNotificationIndicator: function ns_updateNotificationIndicator() { if (this.unreadNotifications.length) { // If the ftu is running we should not show the ambient indicator // because the statusbar is not accessible if (Service.query('isFtuRunning')) { return; } this.ambientIndicator.classList.add('unread'); navigator.mozL10n.setAttributes( this.ambientIndicator, 'statusbarNotifications-unread', {n: this.unreadNotifications.length} ); } else { this.ambientIndicator.classList.remove('unread'); this.ambientIndicator.removeAttribute('aria-label'); } Service.request('UtilityTray:updateNotificationCount'); }, closeToast: function ns_closeToast() { this.toaster.classList.remove('displayed'); this.updateNotificationIndicator(); }, closeNotification: function ns_closeNotification(notificationNode) { var notificationId = notificationNode.dataset.notificationId; this.removeNotification(notificationId); }, removeLockScreenNotification: function ns_removeLockScreenNotification(notificationId) { var lockScreenNotificationNode; var notifSelector = '[data-notification-id="' + notificationId + '"]'; this.lockScreenContainer = this.getLockScreenContainer(); if (this.lockScreenContainer) { lockScreenNotificationNode = this.lockScreenContainer.querySelector(notifSelector); } if (lockScreenNotificationNode) { var lockScreenNotificationParentNode = lockScreenNotificationNode.parentNode; lockScreenNotificationParentNode.removeChild(lockScreenNotificationNode); window.dispatchEvent( new window.CustomEvent('lockscreen-notification-request-remove', { containerEmpty: !lockScreenNotificationParentNode.firstElementChild })); } }, removeNotification: function ns_removeNotification(notificationId) { var notifSelector = '[data-notification-id="' + notificationId + '"]'; var notificationNode = this.container.querySelector(notifSelector); if (notificationNode) { notificationNode.remove(); } var event = document.createEvent('CustomEvent'); event.initCustomEvent('mozContentNotificationEvent', true, true, { type: 'desktop-notification-close', id: notificationId }); window.dispatchEvent(event); // UX require to give a tiny delay for actionable notification on // LockScreen. setTimeout((function() { this.removeLockScreenNotification(notificationId); }).bind(this), 400); this.removeUnreadNotification(notificationId); if (!this.container.querySelector('.notification')) { // no notifications left this.clearAllButton.disabled = true; } }, clearAll: function ns_clearAll() { var notifications = this.container.querySelectorAll('.notification'); var clearable = [].slice.apply(notifications) .filter(function isClearable(notification) { return notification.dataset.noClear !== 'true'; }); var notification; this.clearAllButton.disabled = true; // When focus is on a disabled element, Gecko will not dispatch any keyboard // events to it (the disabled element) and its parent. // The clearAll Button is focused but disabled right away after // user click it. We need to blur the focus immediately, otherwise System // app could not receive any keyboard events. See more detail on // http://bugzil.la/1106844 this.clearAllButton.blur(); if (!clearable.length) { return; } // Adding a callback to the last cleared notification to defer // the destroying of the notifications after the last disappear var lastClearable = clearable[clearable.length - 1]; var removeAll = (function removeAll() { lastClearable.removeEventListener('transitionend', removeAll); for (var notification of clearable) { this.closeNotification(notification); } }).bind(this); lastClearable.addEventListener('transitionend', removeAll); var disappearingClass = document.dir === 'ltr' ? 'disappearing-via-clear-all' : 'disappearing-via-clear-all-left'; for (var index = 0, max = clearable.length; index < max; index++) { notification = clearable[index]; notification.style.transitionDelay = (this.CLEAR_DELAY * index) + 'ms'; notification.classList.add(disappearingClass); notification.style.transform = ''; } }, clearLockScreen: function ns_clearLockScreen() { // The LockScreenWindow may not be instantiated yet. if (!this.lockScreenContainer) { return; } while (this.lockScreenContainer.firstElementChild) { var element = this.lockScreenContainer.firstElementChild; this.lockScreenContainer.removeChild(element); } window.dispatchEvent( new window.CustomEvent('lockscreen-notification-request-clear')); }, resendStoredNotifications: function() { if ('mozSettings' in navigator && navigator.mozSettings) { var key = 'notifications.resend'; var req = navigator.mozSettings.createLock().get(key); req.onsuccess = req.onerror = function onsuccess() { var resendEnabled = req.result[key] || false; if (!resendEnabled) { return; } var resendCallback = (function(number) { window.dispatchEvent( new CustomEvent('desktop-notification-resend', { detail: { number: number } })); }).bind(this); if ('mozChromeNotifications' in navigator) { navigator.mozChromeNotifications. mozResendAllNotifications(resendCallback); } }; } } }; SettingsListener.observe( 'lockscreen.notifications-preview.enabled', true, function(value) { NotificationScreen.lockscreenPreview = value; }); SettingsListener.observe('audio.volume.notification', 7, function(value) { NotificationScreen.silent = (value === 0); }); SettingsListener.observe('vibration.enabled', true, function(value) { NotificationScreen.vibrates = value; });