Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
447 lines
17 KiB
JavaScript
447 lines
17 KiB
JavaScript
'use strict';
|
|
/* global Service, UtilityTrayMotion, TouchForwarder */
|
|
|
|
(function(exports) {
|
|
|
|
/**
|
|
* The utility tray (the draggable drawer at the top of the screen, providing
|
|
* easy access to notifications and quick settings) is composed of two classes:
|
|
*
|
|
* UtilityTrayMotion, managing the scrolling and snapping behavior, and
|
|
* UtilityTray, representing the state of the UtilityTray.
|
|
*
|
|
* NOTE: The UtilityTray and statusbar interact differently in fullscreen modes.
|
|
* There are two kinds of fullscreen modes:
|
|
*
|
|
* - "fullscreen" FxOS Apps
|
|
* - window.requestFullScreen() interactions
|
|
*
|
|
* In both of these fullscreen modes, the utility tray does NOT swipe down
|
|
* immediately. Instead, the first swipe down from the top (via #top-panel)
|
|
* triggers the AppStatusbar to show; then, a second swipe is handled by
|
|
* UtilityTrayMotion to show the utility tray.
|
|
*
|
|
* The expected behavior of this is illustrated in the following video, where
|
|
* the following scenarios are tested:
|
|
*
|
|
* https://youtu.be/57v25D94zFY
|
|
*
|
|
* 1. No app (first swipe triggers UtilityTray)
|
|
* 2. Fullscreen Gaia app (first swipe triggers Statusbar, second UtilityTray)
|
|
* 3. requestFullScreen app (first swipe Statusbar, second UtilityTray)
|
|
*/
|
|
exports.UtilityTray = {
|
|
name: 'UtilityTray',
|
|
|
|
debug: 0 ? console.log.bind(console, '[UtilityTray]') : () => {},
|
|
|
|
shown: false,
|
|
|
|
overlay: document.getElementById('utility-tray'),
|
|
motionElement: document.getElementById('utility-tray-motion'),
|
|
notifications: document.getElementById('utility-tray-notifications'),
|
|
statusbar: document.getElementById('statusbar'),
|
|
statusbarIcons: document.getElementById('statusbar-icons'),
|
|
topPanel: document.getElementById('top-panel'),
|
|
ambientIndicator: document.getElementById('ambient-indicator'),
|
|
grippy: document.getElementById('utility-tray-grippy'),
|
|
invisibleGripper: document.getElementById('tray-invisible-gripper'),
|
|
container: document.getElementById('desktop-notifications-container'),
|
|
notificationsContainer: document.getElementById('notifications-container'),
|
|
nestedScrollInterceptor:
|
|
document.getElementById('notifications-nested-scroll-interceptor'),
|
|
notificationTitle: document.getElementById('notification-some'),
|
|
footer: document.getElementById('utility-tray-footer'),
|
|
footerContainer: document.getElementById('tray-footer-container'),
|
|
screen: document.getElementById('screen'),
|
|
EVENT_PREFIX: 'utilitytray',
|
|
|
|
publish: function(evtName) {
|
|
window.dispatchEvent(new CustomEvent(this.EVENT_PREFIX + evtName, {
|
|
detail: this
|
|
}));
|
|
},
|
|
|
|
isActive: function() {
|
|
return this.shown;
|
|
},
|
|
|
|
focus: function() {},
|
|
blur: function() {},
|
|
|
|
/**
|
|
* Update the max-height of the notifications container to ensure it doesn't
|
|
* overlap with the (position: fixed) footer.
|
|
*/
|
|
recalculateNotificationsContainerHeight() {
|
|
this.notificationsContainer.style.maxHeight =
|
|
(this.footerContainer.offsetTop -
|
|
this.notificationsContainer.offsetTop) + 'px';
|
|
|
|
// Nested scroll momentum typically propagates across parent containers.
|
|
// However, if the notifications container is scrollable, we do *not* want
|
|
// its scroll momentum to propagate to the Utility Tray itself.
|
|
//
|
|
// In Gecko, scroll momentum does *not* propagate through a scrollable
|
|
// container whose content isn't large enough to require it to actually
|
|
// scroll. (I'm not sure if this is a feature or a bug.) In any case, we've
|
|
// inserted `nestedScrollInterceptor` in between the two scrollable areas,
|
|
// and by setting 'overflow: scroll', we can conditionally interrupt the
|
|
// scroll momentum when notifications-container is scrollable.
|
|
// See <https://bugzil.la/1209387>.
|
|
var notificationsCanScroll = (this.notificationsContainer.scrollTopMax > 0);
|
|
this.nestedScrollInterceptor.style.overflowY =
|
|
(notificationsCanScroll ? 'scroll' : 'hidden');
|
|
},
|
|
|
|
toggleFooterDisplay() {
|
|
// If we're nearly fully open, deploy the footer; due to overscroll effects,
|
|
// waiting until the scrolling has stopped completely makes the footer
|
|
// appear sluggish.
|
|
var shouldShow =
|
|
(this.motion.percentVisible === 1) ||
|
|
(this.motion.state === 'opening' && this.motion.percentVisible > 0.95);
|
|
this.footer.classList.toggle('animate-in', shouldShow);
|
|
this.footer.classList.toggle('animate-out', !shouldShow);
|
|
},
|
|
|
|
init: function ut_init() {
|
|
|
|
// Prepare the tray's motion behavior.
|
|
this.motion = new UtilityTrayMotion(this.motionElement);
|
|
this.motion.el.addEventListener('tray-motion-state', this);
|
|
this.motion.el.addEventListener('tray-motion-footer-position', this);
|
|
this.motion.el.classList.remove('utility-tray-loading');
|
|
|
|
// The footer size (containing the media player and cost-control-widget)
|
|
// is variable and difficult to compute with CSS in a maintainable way,
|
|
// especially when we account for future addons or other modifications to
|
|
// the tray. Instead, we calculate the desired height on-the-fly, by
|
|
// observing when key footer elements change relevant state.
|
|
// Ideally, we would observe the whole footer for DOM changes, to make it
|
|
// easier for addon developers, but for now we just observe the relevant
|
|
// changes to the only two variable elements:
|
|
var observer = new MutationObserver(
|
|
this.recalculateNotificationsContainerHeight.bind(this));
|
|
// MediaPlaybackWidget does not currently emit any events when it changes;
|
|
// it is assumed here that MediaPlaybackWidget has a fixed height; here we
|
|
// just listen to the attribute "hidden"'s state.
|
|
observer.observe(document.getElementById('media-playback-container'), {
|
|
attributes: true,
|
|
attributeFilter: ['hidden']
|
|
});
|
|
// It's not clear that the cost control widget ever changes or hides
|
|
// (much of its behavior happens in an iframe), but for completeness:
|
|
observer.observe(document.getElementById('cost-control-widget'), {
|
|
subtree: true,
|
|
childList: true
|
|
});
|
|
// If the notifications container becomes scrollable, we may conditionally
|
|
// update the behavior of this.nestedScrollInterceptor. (We can't just
|
|
// intercept a "notification emitted" event, because there isn't a unified
|
|
// event to listen for.)
|
|
observer.observe(this.notificationsContainer, {
|
|
subtree: true,
|
|
childList: true
|
|
});
|
|
this.recalculateNotificationsContainerHeight();
|
|
|
|
// This is required to prevent b2g-desktop from
|
|
// stealing focus from form fields when the
|
|
// #top-panel is touched. Remove this line if/when
|
|
// b2g-desktop stops disptaching mouse events.
|
|
this.topPanel.addEventListener('mousedown', e => e.preventDefault());
|
|
this.invisibleGripper.addEventListener(
|
|
'mousedown', e => e.preventDefault());
|
|
|
|
window.addEventListener('screenchange', this);
|
|
window.addEventListener('resize', this);
|
|
window.addEventListener('emergencyalert', this);
|
|
window.addEventListener('attentionopened', this);
|
|
window.addEventListener('attentionwill-become-active', this);
|
|
window.addEventListener('launchapp', this);
|
|
window.addEventListener('displayapp', this);
|
|
window.addEventListener('appopening', this);
|
|
window.addEventListener('activityopening', this);
|
|
window.addEventListener('cardviewbeforeshow', this);
|
|
window.addEventListener('sheets-gesture-begin', this);
|
|
|
|
// Listen for screen reader edge gestures
|
|
window.addEventListener('mozChromeEvent', this);
|
|
|
|
// Firing when the IME switcher shows/hides.
|
|
window.addEventListener('imemenushow', this);
|
|
|
|
window.addEventListener('simlockshow', this);
|
|
|
|
// Firing when user selected a new keyboard or canceled it.
|
|
window.addEventListener('keyboardchanged', this);
|
|
window.addEventListener('keyboardchangecanceled', this);
|
|
|
|
this.motion.el.addEventListener('wheel', this);
|
|
this.statusbar.addEventListener('wheel', this);
|
|
|
|
this.invisibleGripper.addEventListener('click',
|
|
this._forwardInvisibleGripperClick.bind(this));
|
|
|
|
Service.request('registerHierarchy', this);
|
|
|
|
Service.register('makeAmbientIndicatorActive', this);
|
|
Service.register('makeAmbientIndicatorInactive', this);
|
|
Service.register('hide', this);
|
|
Service.register('show', this);
|
|
Service.register('updateNotificationCount', this);
|
|
Service.registerState('shown', this);
|
|
},
|
|
|
|
setHierarchy: function() {
|
|
return false;
|
|
},
|
|
|
|
_handle_home: function() {
|
|
if (this.isActive()) {
|
|
this.hide();
|
|
return false;
|
|
}
|
|
return true;
|
|
},
|
|
|
|
respondToHierarchyEvent: function(evt) {
|
|
if (this['_handle_' + evt.type]) {
|
|
return this['_handle_' + evt.type](evt);
|
|
} else {
|
|
return true;
|
|
}
|
|
},
|
|
|
|
hide(immediately) {
|
|
if (this.motion.state === 'open' || this.motion.state === 'opening') {
|
|
this.motion.close(immediately);
|
|
}
|
|
},
|
|
|
|
show(immediately) {
|
|
if (this.motion.state === 'closed' || this.motion.state === 'closing') {
|
|
this.motion.open(immediately);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Handle the tray changing state, e.g. from "opening" to "open". The possible
|
|
* states are "closed", "opening", "open", and "closing", as noted in
|
|
* `utility_tray_motion.js`. We only receive state change notifications if
|
|
* the state actually changed from one to another.
|
|
*
|
|
* For historical reasons, we emit many different events; some could be
|
|
* condensed in the future if desired.
|
|
*
|
|
* @param {string} state
|
|
* @param {string} previousState
|
|
*/
|
|
handleTrayStateChange: function(state, previousState) {
|
|
// Update the state first, so that events receive the proper new state.
|
|
this.shown = (state === 'open');
|
|
|
|
if (state === 'opening') {
|
|
this.recalculateNotificationsContainerHeight();
|
|
if (!this.screen.classList.contains('utility-tray')) {
|
|
setTimeout(() => {
|
|
// If the active app was tracking touches it won't get any more events
|
|
// because of the "pointer-events: none" we're adding. Send a
|
|
// "touchcancel" event accordingly. This happens in a timeout to
|
|
// prevent interrupting the current event loop, in case we arrive here
|
|
// from an existing touch handler.
|
|
var touch = this.motion.currentTouch;
|
|
var app = Service.query('getTopMostWindow');
|
|
if (touch && app && app.config && app.config.oop) {
|
|
app.iframe.sendTouchEvent('touchcancel', [touch.identifier],
|
|
[touch.pageX], [touch.pageY],
|
|
[touch.radiusX], [touch.radiusY],
|
|
[touch.rotationAngle], [touch.force],
|
|
1, 0);
|
|
}
|
|
});
|
|
}
|
|
|
|
this.publish('-overlayopening');
|
|
window.dispatchEvent(new CustomEvent('utilitytraywillshow'));
|
|
} else if (state === 'closing') {
|
|
window.dispatchEvent(new CustomEvent('utilitytraywillhide'));
|
|
} else if (state === 'closed') {
|
|
window.dispatchEvent(new CustomEvent('utility-tray-overlayclosed'));
|
|
window.dispatchEvent(new CustomEvent('utilitytrayhide'));
|
|
this.publish('-deactivated');
|
|
} else if (state === 'open') {
|
|
window.dispatchEvent(new CustomEvent('utility-tray-overlayopened'));
|
|
window.dispatchEvent(new CustomEvent('utilitytrayshow'));
|
|
this.publish('-activated');
|
|
}
|
|
|
|
// If the screen is locked, the tray should remain invisible.
|
|
if ((state === 'opening' || state === 'open') &&
|
|
Service.query('locked')) {
|
|
this.hide(true);
|
|
}
|
|
|
|
if (state === 'opening' && previousState === 'closing') {
|
|
window.dispatchEvent(new CustomEvent('utility-tray-abortclose'));
|
|
} else if (state === 'closing' && previousState === 'opening') {
|
|
window.dispatchEvent(new CustomEvent('utility-tray-abortopen'));
|
|
}
|
|
|
|
this.overlay.setAttribute('aria-hidden', state !== 'open');
|
|
this.screen.classList.toggle('utility-tray', this.shown);
|
|
this.screen.classList.toggle('utility-tray-in-transition',
|
|
state === 'opening' || state === 'closing');
|
|
},
|
|
|
|
handleEvent: function ut_handleEvent(evt) {
|
|
var detail = evt.detail;
|
|
|
|
switch (evt.type) {
|
|
case 'resize':
|
|
this.recalculateNotificationsContainerHeight();
|
|
break;
|
|
case 'tray-motion-state':
|
|
this.handleTrayStateChange(evt.detail.value, evt.detail.previousValue);
|
|
this.toggleFooterDisplay();
|
|
break;
|
|
case 'tray-motion-footer-position':
|
|
this.toggleFooterDisplay();
|
|
break;
|
|
case 'cardviewbeforeshow':
|
|
this.hide(true);
|
|
break;
|
|
case 'attentionopened':
|
|
case 'attentionwill-become-active':
|
|
case 'emergencyalert':
|
|
case 'displayapp':
|
|
case 'keyboardchanged':
|
|
case 'keyboardchangecanceled':
|
|
case 'simlockshow':
|
|
case 'appopening':
|
|
case 'activityopening':
|
|
case 'sheets-gesture-begin':
|
|
case 'imemenushow':
|
|
this.hide();
|
|
break;
|
|
|
|
case 'launchapp':
|
|
// We don't want background apps to trigger this event, otherwise,
|
|
// utility tray will be closed accidentally.
|
|
if (detail && detail.stayBackground) {
|
|
return;
|
|
}
|
|
|
|
var findMyDevice =
|
|
window.location.origin.replace('system', 'findmydevice');
|
|
|
|
var blacklist = [findMyDevice];
|
|
|
|
var isBlockedApp = blacklist.some(function(blockedApp) {
|
|
return blockedApp === detail.origin;
|
|
});
|
|
|
|
if (!isBlockedApp) {
|
|
this.hide();
|
|
}
|
|
break;
|
|
|
|
case 'screenchange':
|
|
// If the screen goes black, hide the tray.
|
|
if (!evt.detail.screenEnabled) {
|
|
this.hide(true);
|
|
}
|
|
break;
|
|
|
|
case 'wheel':
|
|
evt.preventDefault();
|
|
// When the user swipes up/down using the "wheel" accessibility gesture,
|
|
// open and/or close the tray accordingly. (Two-finger swipe up/down.)
|
|
if (evt.deltaMode === evt.DOM_DELTA_PAGE && evt.deltaY) {
|
|
if (this.motion.state === 'open' && evt.deltaY > 0) {
|
|
this.hide(true);
|
|
} else if (this.motion.state === 'closed' && evt.deltaY < 0) {
|
|
this.show(true);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'mozChromeEvent':
|
|
if (evt.detail.type !== 'accessibility-control') {
|
|
break;
|
|
}
|
|
var eventType = JSON.parse(evt.detail.details).eventType;
|
|
if (eventType === 'edge-swipe-down' &&
|
|
!Service.query('locked') &&
|
|
!Service.query('isFtuRunning')) {
|
|
if (this.motion.state === 'open') {
|
|
this.hide(true);
|
|
} else {
|
|
this.show(true);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* When we receive a click on the transparent gripper, forward the click
|
|
* to the element below.
|
|
*
|
|
* Normally, when you swipe down from the top of the screen, your finger
|
|
* latches onto the transparent gripper element resting at the top of the
|
|
* screen. This initiates a scroll, and pulls the tray down.
|
|
*
|
|
* But we only need that element to trigger scrolling, not tapping. We want
|
|
* clicks to pass through the transparent gripper to the app below.
|
|
*
|
|
* NOTE: Ideally, we would want a long-press gesture to also pass through
|
|
* the element, however we cannot forward the long-press gesture to the
|
|
* underlying element (HTMLIFrameElement.sendTouchEvent() happens too late
|
|
* in the pipeline to trigger a long-press, e.g. a copy/paste operation).
|
|
* See <https://groups.google.com/forum/#!topic/mozilla.dev.fxos/1Kh8RrK7QWI>.
|
|
*/
|
|
_forwardInvisibleGripperClick(evt) {
|
|
// The most precise way to forward clicks is to use
|
|
// document.elementFromPoint(), but that causes a reflow.
|
|
// Instead, just use a static hit test area for now.
|
|
var app = Service.query('getTopMostWindow');
|
|
if (!app) {
|
|
return;
|
|
}
|
|
var STATUSBAR_HIT_AREA_HEIGHT = 30;
|
|
var STATUSBAR_HIT_AREA_WIDTH = window.innerWidth / 2;
|
|
|
|
if (evt.clientY < STATUSBAR_HIT_AREA_HEIGHT &&
|
|
evt.clientX < STATUSBAR_HIT_AREA_WIDTH &&
|
|
app.appChrome.urlbar) {
|
|
var forwarder = new TouchForwarder(app.appChrome.urlbar);
|
|
forwarder.forward(evt);
|
|
}
|
|
},
|
|
|
|
updateNotificationCount: function ut_updateNotificationCount() {
|
|
var count = this.notifications.
|
|
querySelectorAll('#desktop-notifications-container .notification, ' +
|
|
'.fake-notification.displayed').length;
|
|
|
|
navigator.mozL10n.setAttributes(this.notificationTitle,
|
|
count ? 'statusbarNotifications' : 'statusbarNoNotifications', {
|
|
n: count
|
|
});
|
|
},
|
|
|
|
makeAmbientIndicatorActive: function ut_makeAmbientIndicatorActive() {
|
|
this.ambientIndicator.classList.add('active');
|
|
},
|
|
|
|
makeAmbientIndicatorInactive: function ut_makeAmbientIndicatorInactive() {
|
|
this.ambientIndicator.classList.remove('active');
|
|
},
|
|
|
|
};
|
|
|
|
|
|
|
|
})(window); // end function wrapper
|