VulpesOS/assets/webapps/system.gaiamobile.org/js/utility_tray.js
OverSu b38dea0597 Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
2026-09-30 23:13:03 +02:00

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