diff options
Diffstat (limited to 'toolkit/mozapps/extensions/content/view-controller.js')
-rw-r--r-- | toolkit/mozapps/extensions/content/view-controller.js | 253 |
1 files changed, 253 insertions, 0 deletions
diff --git a/toolkit/mozapps/extensions/content/view-controller.js b/toolkit/mozapps/extensions/content/view-controller.js new file mode 100644 index 0000000000..0fd8278db7 --- /dev/null +++ b/toolkit/mozapps/extensions/content/view-controller.js @@ -0,0 +1,253 @@ +/* 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 */ + +const { AddonManager } = ChromeUtils.import( + "resource://gre/modules/AddonManager.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "AMTelemetry", + "resource://gre/modules/AddonManager.jsm" +); + +async function recordViewTelemetry(param) { + let type; + let addon; + + if ( + AddonManager.hasAddonType(param) || + ["recent", "available"].includes(param) + ) { + type = param; + } else if (param) { + let id = param.replace("/preferences", ""); + addon = await AddonManager.getAddonByID(id); + } + + let { currentViewId } = gViewController; + let viewType = gViewController.parseViewId(currentViewId)?.type; + let details = { + view: viewType || "other", + addon, + type, + }; + + // The extensions list view does also include recommendations that may be + // recommended by TAAR, themes list view does not at the moment. + if ( + viewType === "discover" || + (viewType === "list" && type === "extension") + ) { + // DiscoveryAPI is defined in aboutaddons.js (which is technically loaded after + // this script, nevertheless we would never reach this if aboutaddons.js wasn't + // already executed and the about:addons views defined, which guarantees that + // DiscoveryAPI will always be defined here. + // (This telemetry collection is also covered by tests and so there will also be + // test failures if that isn't the case anymore). + const { DiscoveryAPI } = window; + details.taarEnabled = !!DiscoveryAPI.clientIdDiscoveryEnabled; + } + + AMTelemetry.recordViewEvent(details); +} + +// 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, data) { + 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; + } + + this.currentViewId = state.view; + this.isLoading = true; + + // Perform tasks before view load + recordViewTelemetry(param); + 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) { + ScrollOffsets.save(); + ScrollOffsets.setView(state.historyEntryId); + + 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 frame before restoring scroll offsets. + await new Promise(resolve => { + window.requestAnimationFrame(() => { + 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); + }, +}; |