/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
/* import-globals-from /toolkit/content/customElements.js */
/* import-globals-from aboutaddonsCommon.js */
/* exported loadView */
// Used by external callers to load a specific view into the manager
function loadView(viewId) {
if (!gViewController.readyForLoadView) {
throw new Error(
"loadView called before about:addons is initialized");
}
gViewController.loadView(viewId);
}
/**
* Helper for saving and restoring the scroll offsets when a previously loaded
* view is accessed again.
*/
var ScrollOffsets = {
_key:
null,
_offsets:
new Map(),
canRestore:
true,
setView(historyEntryId) {
this._key = historyEntryId;
this.canRestore =
true;
},
getPosition() {
if (!
this.canRestore) {
return { top: 0, left: 0 };
}
let { scrollTop: top, scrollLeft: left } = document.documentElement;
return { top, left };
},
save() {
if (
this._key) {
this._offsets.set(
this._key,
this.getPosition());
}
},
restore() {
let { top = 0, left = 0 } =
this._offsets.get(
this._key) || {};
window.scrollTo({ top, left, behavior:
"auto" });
},
};
var gViewController = {
currentViewId:
null,
readyForLoadView:
false,
get defaultViewId() {
if (!isDiscoverEnabled()) {
return "addons://list/extension";
}
return "addons://discover/";
},
isLoading:
true,
// All historyEntryId values must be unique within one session, because the
// IDs are used to map history entries to page state. It is not possible to
// see whether a historyEntryId was used in history entries before this page
// was loaded, so start counting from a random value to avoid collisions.
// This is used for scroll offsets in aboutaddons.js
nextHistoryEntryId: Math.floor(Math.random() * 2 ** 32),
views: {},
initialize(container) {
this.container = container;
window.addEventListener(
"popstate",
this);
window.addEventListener(
"unload",
this, { once:
true });
Services.obs.addObserver(
this,
"EM-ping");
},
handleEvent(e) {
if (e.type ==
"popstate") {
this.renderState(e.state);
return;
}
if (e.type ==
"unload") {
Services.obs.removeObserver(
this,
"EM-ping");
// eslint-disable-next-line no-useless-return
return;
}
},
observe(subject, topic) {
if (topic ==
"EM-ping") {
this.readyForLoadView =
true;
Services.obs.notifyObservers(window,
"EM-pong");
}
},
notifyEMLoaded() {
this.readyForLoadView =
true;
Services.obs.notifyObservers(window,
"EM-loaded");
},
notifyEMUpdateCheckFinished() {
// Notify the observer about a completed update check (currently only used in tests).
Services.obs.notifyObservers(
null,
"EM-update-check-finished");
},
defineView(viewName, renderFunction) {
if (
this.views[viewName]) {
throw new Error(
`about:addons view ${viewName} should not be defined twice`
);
}
this.views[viewName] = renderFunction;
},
parseViewId(viewId) {
const matchRegex = /^addons:\/\/([^\/]+)\/(.*)$/;
const [, viewType, viewParam] = viewId.match(matchRegex) || [];
return { type: viewType, param: decodeURIComponent(viewParam) };
},
loadView(viewId, replace =
false) {
viewId = viewId.startsWith(
"addons://") ? viewId : `addons://${viewId}`;
if (viewId ==
this.currentViewId) {
return Promise.resolve();
}
// Always rewrite history state instead of pushing incorrect state for initial load.
replace = replace || !
this.currentViewId;
const state = {
view: viewId,
previousView: replace ?
null :
this.currentViewId,
historyEntryId: ++
this.nextHistoryEntryId,
};
if (replace) {
history.replaceState(state,
"");
}
else {
history.pushState(state,
"");
}
return this.renderState(state);
},
async renderState(state) {
let { param, type } =
this.parseViewId(state.view);
if (!type ||
this.views[type] ==
null) {
console.warn(`No view
for ${type} ${param}, switching to
default`);
this.resetState();
return;
}
ScrollOffsets.save();
ScrollOffsets.setView(state.historyEntryId);
this.currentViewId = state.view;
this.isLoading =
true;
// Perform tasks before view load
document.dispatchEvent(
new CustomEvent(
"view-selected", {
detail: { id: state.view, param, type },
})
);
// Render the fragment
this.container.setAttribute(
"current-view", type);
let fragment = await
this.views[type](param);
// Clear and append the fragment
if (fragment) {
this.container.textContent =
"";
this.container.append(fragment);
// Most content has been rendered at this point. The only exception are
// recommendations in the discovery pane and extension/theme list, because
// they rely on remote data. If loaded before, then these may be rendered
// within one tick, so wait a full frame before restoring scroll offsets.
await
new Promise(resolve => {
window.requestAnimationFrame(() => {
window.requestAnimationFrame(async () => {
// Ensure all our content is translated.
if (document.hasPendingL10nMutations) {
await
new Promise(r => {
document.addEventListener(
"L10nMutationsFinished", r, {
once:
true,
});
});
}
ScrollOffsets.restore();
resolve();
});
});
});
}
else {
// Reset to default view if no given content
this.resetState();
return;
}
this.isLoading =
false;
document.dispatchEvent(
new CustomEvent(
"view-loaded"));
},
resetState() {
return this.loadView(
this.defaultViewId,
true);
},
};