Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
906 lines
25 KiB
JavaScript
Executable file
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);
|