VulpesOS/gaia/apps/sms/views/shared/js/navigation.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

906 lines
25 KiB
JavaScript
Executable file

/*global
EventDispatcher,
Utils
*/
(function(window) {
'use strict';
/* The factory will be used in unit tests. */
var NavigationFactory = window.NavigationFactory = function(window) {
/**
* @function
* The debug function translates to console.log in debug mode.
*
* @param {String} arg1 The first value to be displayed to the console.
* @param {...*} args The other values to be displayed to the console, or the
* parameters to the first string.
*/
const debug = [
() => {},
(arg1, ...args) => console.log('[Navigation] ' + arg1, ...args),
(arg1, ...args) =>
console.log('[Navigation] (%s) ' + arg1, Date.now(), ...args),
][0];
/**
* This contains an array of view characteristics, necessary to handle
* navigation requests.
* Each item has the following properties:
* - name: this is the name of the view. This will be used also in the URL
* hash if the same HTML file holds several views.
* - url: this is the URL used to match the current location and find the
* current view.
* - behavior: this is the actual JavaScript object that manages this view.
* - previous (optional): this is the name of the previous view; this is
* necessary to decide if we do a forward/backward animation.
* - partOf (optional): this is used when a view is part of another view's
* HTML document. In that case one view is "master" and the other are only
* part of the master view.
*
* NGA_VIEWS holds the configuration for NGA split views.
*/
const NGA_VIEWS = Object.freeze([
Object.freeze({
name: 'thread',
url: 'views/conversation/index.html',
behavior: 'ConversationView',
previous: 'thread-list'
}),
Object.freeze({
name: 'thread-list',
url: 'views/inbox/index.html',
behavior: 'InboxView'
}),
Object.freeze({
name: 'report-view',
behavior: 'ReportView',
previous: 'thread',
partOf: 'thread'
}),
Object.freeze({
name: 'composer',
behavior: 'ConversationView',
previous: 'thread-list',
partOf: 'thread'
}),
Object.freeze({
name: 'group-view',
behavior: 'GroupView',
previous: 'thread',
partOf: 'thread'
})
]);
/**
* OA_VIEWS holds the configuration for the old non-split architecture.
*/
const OA_VIEWS = Object.freeze([
Object.freeze({
name: 'thread-list',
url: 'index.html',
behavior: 'InboxView'
}),
Object.freeze({
name: 'thread',
behavior: 'ConversationView',
previous: 'thread-list',
partOf: 'thread-list'
}),
Object.freeze({
name: 'composer',
behavior: 'ConversationView',
previous: 'thread-list',
partOf: 'thread-list'
}),
Object.freeze({
name: 'group-view',
behavior: 'GroupView',
previous: 'thread',
partOf: 'thread-list'
}),
Object.freeze({
name: 'report-view',
behavior: 'ReportView',
previous: 'thread',
partOf: 'thread-list'
})
]);
const VIEWS = isUsingOldArchitecture() ? OA_VIEWS : NGA_VIEWS;
const priv = {
pendingInit: Symbol('pendingInit')
};
var currentState;
var navigationTransition;
/**
* Navigate to this URL.
*
* @param {String} url URL to load.
*/
function setLocation(url) {
debug('Setting location', url);
window.location.assign(url);
}
/**
* We're using the old architecture if we're not in a split document.
*
* @returns {Boolean} true if we're using the old architecture.
*/
function isUsingOldArchitecture() {
debug('isUsingOldArchitecture()');
var pathname = window.location.pathname;
var result = (pathname === '/' || pathname === '/index.html');
debug('isUsingOldArchitecture() =>', result);
return result;
}
/**
* Parses hash to find a view name.
* It takes the part of the hash between '/' or the start of the string, and '?'
* or the end of the string.
*
* Examples:
* #/conversation?id=5 => conversation
* #/composer?recipient=+33615647897 => composer
* #/composer => composer
*
* @param {String} hash The string to analyze.
* @returns {String} The analyzed string. It is not guaranteed to be an existing
* view name.
*/
function findViewNameFromHash(hash) {
debug('findViewNameFromHash(%s)', hash);
if (!hash || !hash.startsWith('#/')) {
return null;
}
var endOfViewName = hash.indexOf('?');
if (endOfViewName < 0) {
endOfViewName = undefined;
}
return hash.slice(2, endOfViewName);
}
/**
* Tries to find the correct view from the location.
*
* First it tries to find the view using the location's hash.
* If findViewNameFromHash returns something, we try to match an existing view
* from this name, then we check whether the found view is indeed part of this
* document using the full URL.
*
* If findViewNameFromHash returns nothing, we try to find a view from the full
* URL.
*
* @param {Location} location The content of window.location.
* @returns {Object} The found view, or null.
*/
function findViewFromLocation(location) {
debug('findViewFromLocation(%s)', location.href);
var pathName = location.pathname;
if (pathName.endsWith('/')) {
pathName += 'index.html';
}
var hash = location.hash;
var viewNameFromHash = findViewNameFromHash(hash);
var viewFromHash = findViewFromName(viewNameFromHash);
if (viewNameFromHash && !viewFromHash) {
console.error('We found no view from the hash %s', hash);
return null;
}
var viewFromPath = VIEWS.find(
(view) => pathName.endsWith('/' + view.url)
) || null;
if (!viewFromPath) {
console.error('We found no view from the path %s', pathName);
return null;
}
if (viewFromHash && viewFromHash.partOf &&
viewFromHash.partOf !== viewFromPath.name) {
console.error(
'The view %s found from the hash %s is not part of view %s',
viewFromHash.name, hash, viewFromPath.name
);
viewFromHash = null;
}
return viewFromHash || viewFromPath;
}
/**
* Finds a view from its name.
*
* @param {String} name Name of the view to find.
* @returns {Object} The found view, or null.
*/
function findViewFromName(name) {
debug('findViewFromName(%s)', name);
var view = VIEWS.find((view) => view.name === name) || null;
debug('findViewFromName() => %s', view && view.name);
return view;
}
/**
* Finds the container view of a view.
*
* @param {Object} view A view or a subview.
* @returns {Object} the container view of the parameter view. It can be the
* same object if the parameter is itself the container view.
*/
function findContainerView(view) {
debug('findContainerView(%s)', view.name);
if (view.partOf) {
debug('findContainerView: %s is part of view %s', view.name, view.partOf);
view = findViewFromName(view.partOf);
}
debug('findContainerView() => %s', view.name);
return view;
}
/**
* @type {Defer}
* Used at startup time to indicate when the app loaded all dependencies.
* Will be nullified once the app is ready.
* @see Navigation.setReady
* @see waitForReady
*/
var readyDefer = Utils.Promise.defer();
/**
* @returns {Promise} Resolved when the app is ready, rejected if the app is
* ready at call time.
*/
function waitForReady() {
if (!readyDefer) {
return Promise.reject(
new Error('waitForReady was called but status is ready already.')
);
}
return readyDefer.promise;
}
function startNavigationFromCurrentLocation() {
var location = window.location;
debug('startNavigationFromCurrentLocation(), location=%s', location);
var newPanel = findViewFromLocation(location);
if (!newPanel) {
return Promise.reject(
new Error('Couldn\'t find a view from location ' + location.href)
);
}
var args = Utils.params(decodeURIComponent(location.hash));
debug(
'startNavigationFromCurrentLocation:',
'found panel', newPanel.name, 'with args', args
);
return startNavigation({ newPanel, args });
}
/**
* Sets up the state to handle the navigation.
* Generally we're waiting for any ongoing transition to end before starting a
* new one. However if the ongoing transition was started with opts.partial ==
* true, we merely augment the ongoing transition with the new information,
* especially arguments.
*
* @param {Object} opts options
* @param {String} [opts.newPanel] The panel we'll switch to. This can be
* omitted in case we only want to execute beforeLeave.
* @param {Object} [opts.args] The arguments we'll use for the next panel.
* @param {Boolean} [opts.partial] If present and true, this new navigation
* request is a partial one missing information (eg arguments) and we'll call
* startNavigation once again without this property once we'll know the missing
* bits.
*
* @returns {Promise} Resolved when we could start the navigation (when a
* previous ongoing one is finished).
*/
function startNavigation(opts) {
var { newPanel, args, partial } = opts;
args = args || {};
debug(
'startNavigation(newPanel=%s, partial=%s)',
newPanel && newPanel.name, partial
);
if (navigationTransition && (!navigationTransition.partial || partial)) {
debug('startNavigation: previous navigation exists, deferring.');
return navigationTransition.defer.promise.then(
() => startNavigation(opts)
);
}
var newContainerView = newPanel && findContainerView(newPanel);
var containerView = currentState && findContainerView(currentState.panel);
var leaveDocument = currentState && containerView !== newContainerView;
if (newPanel && !leaveDocument) {
var behaviorObject = window[newPanel.behavior];
var viewReady =
behaviorObject &&
(typeof behaviorObject.isReady !== 'function' ||
behaviorObject.isReady());
if (!viewReady) {
// This panel is not ready yet, let's wait until the app is ready.
return waitForReady().then(
() => startNavigation(opts)
);
}
}
// Remove the focus before navigating.
document.activeElement.blur();
if (!navigationTransition) {
navigationTransition = {
stepsRun: [],
defer: Utils.Promise.defer()
};
}
navigationTransition.partial = partial;
navigationTransition.leaveDocument = leaveDocument;
if ('meta' in args) {
throw new Error('Arguments cannot have a `meta` property.');
}
navigationTransition.transitionArgs = {
meta: {
next: newPanel ? {
panel: newPanel.name,
args
} : null,
prev: currentState ? {
panel: currentState.panel.name,
args: currentState.args
} : null
}
};
Object.assign(navigationTransition.transitionArgs, args);
navigationTransition.states = {
from: currentState,
to: {
panel: newPanel,
args
}
};
return Promise.resolve();
}
/**
* Sets the currentState variable to its new value.
*/
function setFutureState() {
debug('setFutureState()');
currentState = navigationTransition.states.to;
}
/**
* Ends the navigation so that new navigations can take place.
* This cleans up the navigation states.
*/
function endNavigation() {
debug('endNavigation()');
if (navigationTransition) {
navigationTransition.defer.resolve();
navigationTransition = null;
}
}
/**
* This function is used to catch errors at the end of navigation promise
* chains. We're ending the navigation if this happens, so that we can start a
* new one afterwards.
*
* @param {Error} [e] The exception to display, if any.
*/
function onNavigationError(e = new Error('RejectedPromise')) {
console.error('Navigation error', e);
endNavigation();
return Promise.reject(e);
}
/**
* This function is used as a rejection handler, to swallow errors and log them.
*
* @param {Exception} [e] The exception to display, if any.
*/
function catchStepError(e) {
if (e) {
console.error(
'[Navigation] got an exception: (%s) %s',
e, e.stack && e.stack.replace(/\n/g, '|')
);
} else {
debug('[Navigation] got a rejected promise');
}
}
/**
* Executes the lifecycle function for `stepName` on the right object.
*
* @param {String} stepName The step to execute: one of 'beforeLeave',
* 'beforeEnter', 'afterLeave', 'afterEnter'.
* @returns {Promise} Resolved when the step returns a value or a resolved
* promise, rejected when the step either throws or returns a rejected promise.
* Or a resolved promise if no step was executed (because it was already
* executed or there was no function to execute).
*/
function executeNavigationStep(stepName) {
debug('executeNavigationStep(%s)', stepName);
if (!navigationTransition) {
return Promise.reject(new Error(
'Trying to execute the navigation step ' + stepName +
' but no transition is started yet.'
));
}
if (navigationTransition.stepsRun.indexOf(stepName) >= 0) {
// this step has already run (case of back -> onHashChange)
debug('executeNavigationStep: step %s has already run.', stepName);
return Promise.resolve();
}
var stateKey = stepName.endsWith('Leave') ? 'from' : 'to';
var state = navigationTransition.states[stateKey];
if (!state) {
debug('Nothing to execute for step', stepName);
return Promise.resolve();
}
var panel = state.panel;
var resultPromise = Promise.resolve();
var viewObject = window[panel.behavior];
if (viewObject[stepName]) {
// encapsulating as a then handler to catch exceptions.
resultPromise = resultPromise.then(
() => viewObject[stepName](navigationTransition.transitionArgs)
);
}
return resultPromise.then(() => {
navigationTransition.stepsRun.push(stepName);
debug(`Finished executeNavigationStep(${stepName})`);
});
}
/**
* Used in `waitForPanelAnimation` to help solving race conditions.
* @type {Defer}
*/
var previousAnimationDefer;
/**
* Wait for the end of an animation on `panelElement`.
*
* @param {Element} panelElement The element to watch.
* @returns {Promise} Resolves when the animation ends.
*/
function waitForPanelAnimation(panelElement) {
if (previousAnimationDefer) {
previousAnimationDefer.reject(new Error('A new animation started'));
}
var defer = Utils.Promise.defer();
previousAnimationDefer = defer;
var timeout;
function onAnimationEnd(e) {
panelElement.removeEventListener('animationend', onAnimationEnd);
clearTimeout(timeout);
debug('animationend', e && e.type);
defer.resolve();
}
panelElement.addEventListener('animationend', onAnimationEnd);
timeout = setTimeout(onAnimationEnd, 500);
return defer.promise.then(() => previousAnimationDefer = null);
}
/**
* Hides current panel, shows new panel.
* It will use an animation if we change panels in the same document.
*
* @returns {Promise} Resolves when the animation is over, if there is an
* animation. Otherwise returns a resolved promise.
*/
function switchPanel() {
debug('switchPanel()');
if (!navigationTransition) {
throw new Error(
'Trying to switch panel but no transition is started yet.'
);
}
var from = navigationTransition.states.from;
var to = navigationTransition.states.to;
var oldView;
var oldPanelElement;
if (from) {
oldView = from.panel;
oldPanelElement = document.querySelector(`.panel-${oldView.behavior}`);
if (!oldPanelElement) {
throw new Error(
'Couldn\'t find the container element for view ' + oldView.name
);
}
}
var newView = to.panel;
if (!newView) {
throw new Error(
'Trying to switch panel, but no new view is defined!'
);
}
var newPanelElement = document.querySelector(`.panel-${newView.behavior}`);
if (!newPanelElement) {
return Promise.reject(new Error(
'Couldn\'t find the container element for view ' + newView.name
));
}
if (newPanelElement === oldPanelElement) {
return Promise.resolve();
}
var isGoingBack = oldView && oldView.previous === newView.name;
newPanelElement.style = '';
newPanelElement.classList.remove('panel-hidden');
newPanelElement.classList.add('panel-active');
var doSlideAnimation =
from && from.panel &&
from.panel.behavior !== newView.behavior;
var doFadeInAnimation = !from || !from.panel;
var animationPromise;
if (doSlideAnimation) {
var shouldGoRight = isGoingBack ^ (document.dir === 'rtl');
newPanelElement.style.animationName =
shouldGoRight ? 'left-to-current' : 'right-to-current';
oldPanelElement.style.animationName =
shouldGoRight ? 'current-to-right' : 'current-to-left';
} else if (doFadeInAnimation) {
newPanelElement.style.animationName = 'fade-in';
}
if (doFadeInAnimation || doSlideAnimation) {
animationPromise = waitForPanelAnimation(newPanelElement).catch(
() => {}
).then(() => {
if (oldPanelElement) {
oldPanelElement.style = '';
}
newPanelElement.style = '';
});
}
return (animationPromise || Promise.resolve()).then(() => {
if (oldPanelElement) {
oldPanelElement.classList.remove('panel-active');
oldPanelElement.setAttribute('aria-hidden', 'true');
}
newPanelElement.setAttribute('aria-hidden', 'false');
});
}
/**
* Reacts to hash changes.
*
* This is the heart of the navigation logic and handles running the various
* navigation steps.
* It also handles any unwanted errors. (TODO: needs to tune this a little
* more).
*/
function onHashChange() {
debug('onHashChange, location=', window.location);
startNavigationFromCurrentLocation().then(
() => executeNavigationStep('beforeLeave')
).then(
() => executeNavigationStep('beforeEnter').catch(catchStepError)
).then(
setFutureState
).then(
switchPanel
).then(
() => executeNavigationStep('afterEnter').catch(catchStepError)
).then(
() => executeNavigationStep('afterLeave').catch(catchStepError)
).then(
endNavigation
).then(
() => Navigation.emit('navigated')
).catch(onNavigationError); // TODO reset to previous hash or call back ?
}
/**
* Attaches the hashchange event handler.
*/
function attachHistoryListener() {
window.addEventListener('hashchange', onHashChange);
}
/**
* Removes the hashchange event handler.
*/
function detachHistoryListener() {
window.removeEventListener('hashchange', onHashChange);
}
var Navigation = {
/**
* Calls `window.back` after running the `beforeLeave` step.
*
* We'll either (1) stay in the same document, or (2) move back to an earlier
* document.
*
* In case 1), this will only change the hash, and then the onHashChange event
* handler takes care of the end of the transition.
* In case 2), the full URL will change, and the new window's init method will
* take care of the end of the transition.
*
* @returns {Promise} Resolves when the action is done, if we stay in the same
* document.
*/
back() {
debug('back()');
return startNavigation({ partial: true }).then(
() => executeNavigationStep('beforeLeave')
).then(
() => window.history.back()
).then(
() => navigationTransition.defer.promise,
onNavigationError
);
// todo reset navigation on "pageshow" event ?
},
/**
* Lifecycle methods are generally called in this order:
* - previousPanel.beforeLeave
* - nextPanel.beforeEnter
* - nextPanel.afterEnter
* - previousPanel.afterLeave
*
* If we stay in the same document and enter a new panel, the panels are slid
* between beforeEnter and afterLeave.
*
* With the new architecture, the navigation can happen in one of 2 cases:
* 1. either we stay in the same document,
* 2. or we move to another document.
*
* In case 1), we only change the hash, and then the onHashChange event
* handler takes care of the end of the transition.
* In case 2), we change the full URL, and the new window's init method will
* take care of the end of the transition.
*
* In all cases:
* - beforeLeave can prevent changing the location and thus the
* navigation. It is expected that beforeLeave does not change any state but
* merely checks whether the transition can take place.
* - beforeEnter will be called before the view is displayed. Therefore it can
* make visual changes but it can't prevent a view from being entered. All
* rejections and exceptions will be swallowed and output to the console.
* - afterEnter does left-over actions
* - afterLeave needs to clean up the panel, reclaim memory. It's called only
* when the user stays in the same document.
*
* toPanel itself returns a Promise that will be resolved if the transition
* happened and rejected otherwise.
*
* @param {String} viewName The view to load.
* @param {Object} [args] The arguments to pass to the view.
* @returns {Promise} Resolves when the transition is over, if we stay in the
* same document.
*/
toPanel(viewName, args) {
debug('toPanel(%s)', viewName);
var view = findViewFromName(viewName);
if (!view) {
return Promise.reject(
new Error(`Couldn't find view ${viewName}`)
);
}
if (this.isCurrentPanel(viewName, args)) {
debug('View %s is the current view.', viewName);
return Promise.resolve();
}
if (navigationTransition) {
debug('toPanel: previous navigation exists, deferring.');
return navigationTransition.defer.promise.then(
() => this.toPanel(viewName, args)
);
}
// Set pendingInit to false if we start to navigate to other panel.
this[priv.pendingInit] = false;
var hash = '#';
if (view.partOf) {
hash += '/' + viewName;
}
hash = Utils.url(hash, args);
return startNavigation(
{ newPanel: view, args, partial: true }
).then(
() => executeNavigationStep('beforeLeave')
).then(() => {
var nextLocation;
if (navigationTransition.leaveDocument) {
var containerView = findContainerView(view);
nextLocation = '/' + containerView.url + hash;
} else {
nextLocation = hash;
}
setLocation(nextLocation);
}).then(
() => navigationTransition.defer.promise,
onNavigationError
);
// TODO do something on pageshow?
//
},
/**
* This is called when a view is started.
*
* It tries to find the right view from the location, and then initiates a
* navigation from this.
*/
init() {
debug('init()');
attachHistoryListener();
// Early reject when app is started via notification
if (navigator.mozHasPendingMessage('notification')) {
this[priv.pendingInit] = true;
return Promise.reject();
}
this[priv.pendingInit] = false;
return startNavigationFromCurrentLocation().then(
// right away as we don't execute anything on the previous panel, and we
// need a state at startup.
setFutureState
).then(
() => executeNavigationStep('beforeEnter').catch(catchStepError)
).then(
switchPanel
).then(
() => executeNavigationStep('afterEnter').catch(catchStepError)
).then(
endNavigation
).then(
() => Navigation.emit('navigated')
).catch(onNavigationError);
},
[priv.pendingInit]: false,
hasPendingInit() {
return this[priv.pendingInit];
},
/* will be used by tests */
cleanup() {
detachHistoryListener();
},
/**
* Checks if current location.hash corresponds to default panel. This is
* sometimes called before we resolve the state, in that case resolve
* directly using findViewNameFromHash. Otherwise we use the resolved state.
*
* @returns {boolean} True if current location.hash corresponds to default
* panel.
*/
isDefaultPanel() {
return currentState ?
!currentState.panel.partOf :
!findViewNameFromHash(window.location.hash);
},
/**
* Called with a first argument that is not an object, it returns whether the
* current panel is this first argument.
*
* Called with 2 arguments, the first one is not an object, the second one
* must be an object. It returns whether the current panel is the first
* argument, and for each property of the second argument, whether its value
* is equal to the value for the current panel's arguments' same property.
*
* @param {String} panelName Checks if the current panel is this panelName.
* @param {Object} [args] Checks against the current panel enter
* arguments.
* @returns {boolean} Result of the comparison.
*/
isCurrentPanel(panelName, args) {
debug('isCurrentPanel(%s)', panelName);
if (!currentState || !panelName) {
return false;
}
if (panelName !== currentState.panel.name) {
return false;
}
if (!args) {
return true;
}
var currentArgs = currentState.args || {};
for (var arg in args) {
if (args[arg] != currentArgs[arg]) {
return false;
}
}
return true;
},
/**
* Called by startup scripts to indicate when all lazy loaded files are
* downloaded.
*
* TODO: lazyload the necessary files directly in this file.
*/
setReady() {
debug('setReady()');
Array.from(document.querySelectorAll('.panel-hidden')).forEach((panel) => {
panel.classList.remove('panel-hidden');
});
readyDefer.resolve();
readyDefer = null;
}
};
return EventDispatcher.mixin(Navigation, ['navigated']);
};
window.Navigation = NavigationFactory(window);
})(window);