summaryrefslogtreecommitdiffstats
path: root/browser/components/newtab/lib/ToolbarPanelHub.jsm
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--browser/components/newtab/lib/ToolbarPanelHub.jsm612
1 files changed, 612 insertions, 0 deletions
diff --git a/browser/components/newtab/lib/ToolbarPanelHub.jsm b/browser/components/newtab/lib/ToolbarPanelHub.jsm
new file mode 100644
index 0000000000..53e08c026b
--- /dev/null
+++ b/browser/components/newtab/lib/ToolbarPanelHub.jsm
@@ -0,0 +1,612 @@
+/* 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";
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ PanelMultiView: "resource:///modules/PanelMultiView.sys.mjs",
+ Preferences: "resource://gre/modules/Preferences.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+ RemoteL10n: "resource://activity-stream/lib/RemoteL10n.sys.mjs",
+
+ SpecialMessageActions:
+ "resource://messaging-system/lib/SpecialMessageActions.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ EveryWindow: "resource:///modules/EveryWindow.jsm",
+});
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "TrackingDBService",
+ "@mozilla.org/tracking-db-service;1",
+ "nsITrackingDBService"
+);
+
+const idToTextMap = new Map([
+ [Ci.nsITrackingDBService.TRACKERS_ID, "trackerCount"],
+ [Ci.nsITrackingDBService.TRACKING_COOKIES_ID, "cookieCount"],
+ [Ci.nsITrackingDBService.CRYPTOMINERS_ID, "cryptominerCount"],
+ [Ci.nsITrackingDBService.FINGERPRINTERS_ID, "fingerprinterCount"],
+ [Ci.nsITrackingDBService.SOCIAL_ID, "socialCount"],
+]);
+
+const WHATSNEW_ENABLED_PREF = "browser.messaging-system.whatsNewPanel.enabled";
+const PROTECTIONS_PANEL_INFOMSG_PREF =
+ "browser.protections_panel.infoMessage.seen";
+
+const TOOLBAR_BUTTON_ID = "whats-new-menu-button";
+const APPMENU_BUTTON_ID = "appMenu-whatsnew-button";
+
+const BUTTON_STRING_ID = "cfr-whatsnew-button";
+const WHATS_NEW_PANEL_SELECTOR = "PanelUI-whatsNew-message-container";
+
+class _ToolbarPanelHub {
+ constructor() {
+ this.triggerId = "whatsNewPanelOpened";
+ this._showAppmenuButton = this._showAppmenuButton.bind(this);
+ this._hideAppmenuButton = this._hideAppmenuButton.bind(this);
+ this._showToolbarButton = this._showToolbarButton.bind(this);
+ this._hideToolbarButton = this._hideToolbarButton.bind(this);
+ this.insertProtectionPanelMessage =
+ this.insertProtectionPanelMessage.bind(this);
+
+ this.state = {};
+ this._initialized = false;
+ }
+
+ async init(waitForInitialized, { getMessages, sendTelemetry }) {
+ if (this._initialized) {
+ return;
+ }
+
+ this._initialized = true;
+ this._getMessages = getMessages;
+ this._sendTelemetry = sendTelemetry;
+ // Wait for ASRouter messages to become available in order to know
+ // if we can show the What's New panel
+ await waitForInitialized;
+ // Enable the application menu button so that the user can access
+ // the panel outside of the toolbar button
+ await this.enableAppmenuButton();
+
+ this.state = {
+ protectionPanelMessageSeen: Services.prefs.getBoolPref(
+ PROTECTIONS_PANEL_INFOMSG_PREF,
+ false
+ ),
+ };
+ }
+
+ uninit() {
+ this._initialized = false;
+ lazy.EveryWindow.unregisterCallback(TOOLBAR_BUTTON_ID);
+ lazy.EveryWindow.unregisterCallback(APPMENU_BUTTON_ID);
+ }
+
+ get messages() {
+ return this._getMessages({
+ template: "whatsnew_panel_message",
+ triggerId: "whatsNewPanelOpened",
+ returnAll: true,
+ });
+ }
+
+ toggleWhatsNewPref(event) {
+ // Checkbox onclick handler gets called before the checkbox state gets toggled,
+ // so we have to call it with the opposite value.
+ let newValue = !event.target.checked;
+ lazy.Preferences.set(WHATSNEW_ENABLED_PREF, newValue);
+
+ this.sendUserEventTelemetry(
+ event.target.ownerGlobal,
+ "WNP_PREF_TOGGLE",
+ // Message id is not applicable in this case, the notification state
+ // is not related to a particular message
+ { id: "n/a" },
+ { value: { prefValue: newValue } }
+ );
+ }
+
+ maybeInsertFTL(win) {
+ win.MozXULElement.insertFTLIfNeeded("browser/newtab/asrouter.ftl");
+ win.MozXULElement.insertFTLIfNeeded("toolkit/branding/brandings.ftl");
+ win.MozXULElement.insertFTLIfNeeded("toolkit/branding/accounts.ftl");
+ }
+
+ maybeLoadCustomElement(win) {
+ if (!win.customElements.get("remote-text")) {
+ Services.scriptloader.loadSubScript(
+ "resource://activity-stream/data/custom-elements/paragraph.js",
+ win
+ );
+ }
+ }
+
+ // Turns on the Appmenu (hamburger menu) button for all open windows and future windows.
+ async enableAppmenuButton() {
+ if ((await this.messages).length) {
+ lazy.EveryWindow.registerCallback(
+ APPMENU_BUTTON_ID,
+ this._showAppmenuButton,
+ this._hideAppmenuButton
+ );
+ }
+ }
+
+ // Removes the button from the Appmenu.
+ // Only used in tests.
+ disableAppmenuButton() {
+ lazy.EveryWindow.unregisterCallback(APPMENU_BUTTON_ID);
+ }
+
+ // Turns on the Toolbar button for all open windows and future windows.
+ async enableToolbarButton() {
+ if ((await this.messages).length) {
+ lazy.EveryWindow.registerCallback(
+ TOOLBAR_BUTTON_ID,
+ this._showToolbarButton,
+ this._hideToolbarButton
+ );
+ }
+ }
+
+ // When the panel is hidden we want to run some cleanup
+ _onPanelHidden(win) {
+ const panelContainer = win.document.getElementById(
+ "customizationui-widget-panel"
+ );
+ // When the panel is hidden we want to remove any toolbar buttons that
+ // might have been added as an entry point to the panel
+ const removeToolbarButton = () => {
+ lazy.EveryWindow.unregisterCallback(TOOLBAR_BUTTON_ID);
+ };
+ if (!panelContainer) {
+ return;
+ }
+ panelContainer.addEventListener("popuphidden", removeToolbarButton, {
+ once: true,
+ });
+ }
+
+ // Newer messages first and use `order` field to decide between messages
+ // with the same timestamp
+ _sortWhatsNewMessages(m1, m2) {
+ // Sort by published_date in descending order.
+ if (m1.content.published_date === m2.content.published_date) {
+ // Ascending order
+ return m1.order - m2.order;
+ }
+ if (m1.content.published_date > m2.content.published_date) {
+ return -1;
+ }
+ return 1;
+ }
+
+ // Render what's new messages into the panel.
+ async renderMessages(win, doc, containerId, options = {}) {
+ // Set the checked status of the footer checkbox
+ let value = lazy.Preferences.get(WHATSNEW_ENABLED_PREF);
+ let checkbox = win.document.getElementById("panelMenu-toggleWhatsNew");
+
+ checkbox.checked = value;
+
+ this.maybeLoadCustomElement(win);
+ const messages =
+ (options.force && options.messages) ||
+ (await this.messages).sort(this._sortWhatsNewMessages);
+ const container = lazy.PanelMultiView.getViewNode(doc, containerId);
+
+ if (messages) {
+ // Targeting attribute state might have changed making new messages
+ // available and old messages invalid, we need to refresh
+ this.removeMessages(win, containerId);
+ let previousDate = 0;
+ // Get and store any variable part of the message content
+ this.state.contentArguments = await this._contentArguments();
+ for (let message of messages) {
+ container.appendChild(
+ this._createMessageElements(win, doc, message, previousDate)
+ );
+ previousDate = message.content.published_date;
+ }
+ }
+
+ this._onPanelHidden(win);
+
+ // Panel impressions are not associated with one particular message
+ // but with a set of messages. We concatenate message ids and send them
+ // back for every impression.
+ const eventId = {
+ id: messages
+ .map(({ id }) => id)
+ .sort()
+ .join(","),
+ };
+ // Check `mainview` attribute to determine if the panel is shown as a
+ // subview (inside the application menu) or as a toolbar dropdown.
+ // https://searchfox.org/mozilla-central/rev/07f7390618692fa4f2a674a96b9b677df3a13450/browser/components/customizableui/PanelMultiView.jsm#1268
+ const mainview = win.PanelUI.whatsNewPanel.hasAttribute("mainview");
+ this.sendUserEventTelemetry(win, "IMPRESSION", eventId, {
+ value: { view: mainview ? "toolbar_dropdown" : "application_menu" },
+ });
+ }
+
+ removeMessages(win, containerId) {
+ const doc = win.document;
+ const messageNodes = lazy.PanelMultiView.getViewNode(
+ doc,
+ containerId
+ ).querySelectorAll(".whatsNew-message");
+ for (const messageNode of messageNodes) {
+ messageNode.remove();
+ }
+ }
+
+ /**
+ * Dispatch the action defined in the message and user telemetry event.
+ */
+ _dispatchUserAction(win, message) {
+ let url;
+ try {
+ // Set platform specific path variables for SUMO articles
+ url = Services.urlFormatter.formatURL(message.content.cta_url);
+ } catch (e) {
+ console.error(e);
+ url = message.content.cta_url;
+ }
+ lazy.SpecialMessageActions.handleAction(
+ {
+ type: message.content.cta_type,
+ data: {
+ args: url,
+ where: message.content.cta_where || "tabshifted",
+ },
+ },
+ win.browser
+ );
+
+ this.sendUserEventTelemetry(win, "CLICK", message);
+ }
+
+ /**
+ * Attach event listener to dispatch message defined action.
+ */
+ _attachCommandListener(win, element, message) {
+ // Add event listener for `mouseup` not to overlap with the
+ // `mousedown` & `click` events dispatched from PanelMultiView.sys.mjs
+ // https://searchfox.org/mozilla-central/rev/7531325c8660cfa61bf71725f83501028178cbb9/browser/components/customizableui/PanelMultiView.jsm#1830-1837
+ element.addEventListener("mouseup", () => {
+ this._dispatchUserAction(win, message);
+ });
+ element.addEventListener("keyup", e => {
+ if (e.key === "Enter" || e.key === " ") {
+ this._dispatchUserAction(win, message);
+ }
+ });
+ }
+
+ _createMessageElements(win, doc, message, previousDate) {
+ const { content } = message;
+ const messageEl = lazy.RemoteL10n.createElement(doc, "div");
+ messageEl.classList.add("whatsNew-message");
+
+ // Only render date if it is different from the one rendered before.
+ if (content.published_date !== previousDate) {
+ messageEl.appendChild(
+ lazy.RemoteL10n.createElement(doc, "p", {
+ classList: "whatsNew-message-date",
+ content: new Date(content.published_date).toLocaleDateString(
+ "default",
+ {
+ month: "long",
+ day: "numeric",
+ year: "numeric",
+ }
+ ),
+ })
+ );
+ }
+
+ const wrapperEl = lazy.RemoteL10n.createElement(doc, "div");
+ wrapperEl.doCommand = () => this._dispatchUserAction(win, message);
+ wrapperEl.classList.add("whatsNew-message-body");
+ messageEl.appendChild(wrapperEl);
+
+ if (content.icon_url) {
+ wrapperEl.classList.add("has-icon");
+ const iconEl = lazy.RemoteL10n.createElement(doc, "img");
+ iconEl.src = content.icon_url;
+ iconEl.classList.add("whatsNew-message-icon");
+ if (content.icon_alt && content.icon_alt.string_id) {
+ doc.l10n.setAttributes(iconEl, content.icon_alt.string_id);
+ } else {
+ iconEl.setAttribute("alt", content.icon_alt);
+ }
+ wrapperEl.appendChild(iconEl);
+ }
+
+ wrapperEl.appendChild(this._createMessageContent(win, doc, content));
+
+ if (content.link_text) {
+ const anchorEl = lazy.RemoteL10n.createElement(doc, "a", {
+ classList: "text-link",
+ content: content.link_text,
+ });
+ anchorEl.doCommand = () => this._dispatchUserAction(win, message);
+ wrapperEl.appendChild(anchorEl);
+ }
+
+ // Attach event listener on entire message container
+ this._attachCommandListener(win, messageEl, message);
+
+ return messageEl;
+ }
+
+ /**
+ * Return message title (optional subtitle) and body
+ */
+ _createMessageContent(win, doc, content) {
+ const wrapperEl = new win.DocumentFragment();
+
+ wrapperEl.appendChild(
+ lazy.RemoteL10n.createElement(doc, "h2", {
+ classList: "whatsNew-message-title",
+ content: content.title,
+ attributes: this.state.contentArguments,
+ })
+ );
+
+ wrapperEl.appendChild(
+ lazy.RemoteL10n.createElement(doc, "p", {
+ content: content.body,
+ classList: "whatsNew-message-content",
+ attributes: this.state.contentArguments,
+ })
+ );
+
+ return wrapperEl;
+ }
+
+ _createHeroElement(win, doc, message) {
+ this.maybeLoadCustomElement(win);
+
+ const messageEl = lazy.RemoteL10n.createElement(doc, "div");
+ messageEl.setAttribute("id", "protections-popup-message");
+ messageEl.classList.add("whatsNew-hero-message");
+ const wrapperEl = lazy.RemoteL10n.createElement(doc, "div");
+ wrapperEl.classList.add("whatsNew-message-body");
+ messageEl.appendChild(wrapperEl);
+
+ wrapperEl.appendChild(
+ lazy.RemoteL10n.createElement(doc, "h2", {
+ classList: "whatsNew-message-title",
+ content: message.content.title,
+ })
+ );
+ wrapperEl.appendChild(
+ lazy.RemoteL10n.createElement(doc, "p", {
+ classList: "protections-popup-content",
+ content: message.content.body,
+ })
+ );
+
+ if (message.content.link_text) {
+ let linkEl = lazy.RemoteL10n.createElement(doc, "a", {
+ classList: "text-link",
+ content: message.content.link_text,
+ });
+ linkEl.disabled = true;
+ wrapperEl.appendChild(linkEl);
+ this._attachCommandListener(win, linkEl, message);
+ } else {
+ this._attachCommandListener(win, wrapperEl, message);
+ }
+
+ return messageEl;
+ }
+
+ async _contentArguments() {
+ const { defaultEngine } = Services.search;
+ // Between now and 6 weeks ago
+ const dateTo = new Date();
+ const dateFrom = new Date(dateTo.getTime() - 42 * 24 * 60 * 60 * 1000);
+ const eventsByDate = await lazy.TrackingDBService.getEventsByDateRange(
+ dateFrom,
+ dateTo
+ );
+ // Make sure we set all types of possible values to 0 because they might
+ // be referenced by fluent strings
+ let totalEvents = { blockedCount: 0 };
+ for (let blockedType of idToTextMap.values()) {
+ totalEvents[blockedType] = 0;
+ }
+ // Count all events in the past 6 weeks. Returns an object with:
+ // `blockedCount` total number of blocked resources
+ // {tracker|cookie|social...} breakdown by event type as defined by `idToTextMap`
+ totalEvents = eventsByDate.reduce((acc, day) => {
+ const type = day.getResultByName("type");
+ const count = day.getResultByName("count");
+ acc[idToTextMap.get(type)] = (acc[idToTextMap.get(type)] || 0) + count;
+ acc.blockedCount += count;
+ return acc;
+ }, totalEvents);
+ return {
+ // Keys need to match variable names used in asrouter.ftl
+ // `earliestDate` will be either 6 weeks ago or when tracking recording
+ // started. Whichever is more recent.
+ earliestDate: Math.max(
+ new Date(await lazy.TrackingDBService.getEarliestRecordedDate()),
+ dateFrom
+ ),
+ ...totalEvents,
+ // Passing in `undefined` as string for the Fluent variable name
+ // in order to match and select the message that does not require
+ // the variable.
+ searchEngineName: defaultEngine ? defaultEngine.name : "undefined",
+ };
+ }
+
+ async _showAppmenuButton(win) {
+ this.maybeInsertFTL(win);
+ await this._showElement(
+ win.browser.ownerDocument,
+ APPMENU_BUTTON_ID,
+ BUTTON_STRING_ID
+ );
+ }
+
+ _hideAppmenuButton(win, windowClosed) {
+ // No need to do something if the window is going away
+ if (!windowClosed) {
+ this._hideElement(win.browser.ownerDocument, APPMENU_BUTTON_ID);
+ }
+ }
+
+ _showToolbarButton(win) {
+ const document = win.browser.ownerDocument;
+ this.maybeInsertFTL(win);
+ return this._showElement(document, TOOLBAR_BUTTON_ID, BUTTON_STRING_ID);
+ }
+
+ _hideToolbarButton(win) {
+ this._hideElement(win.browser.ownerDocument, TOOLBAR_BUTTON_ID);
+ }
+
+ _showElement(document, id, string_id) {
+ const el = lazy.PanelMultiView.getViewNode(document, id);
+ document.l10n.setAttributes(el, string_id);
+ el.hidden = false;
+ }
+
+ _hideElement(document, id) {
+ const el = lazy.PanelMultiView.getViewNode(document, id);
+ if (el) {
+ el.hidden = true;
+ }
+ }
+
+ _sendPing(ping) {
+ this._sendTelemetry({
+ type: "TOOLBAR_PANEL_TELEMETRY",
+ data: { action: "whats-new-panel_user_event", ...ping },
+ });
+ }
+
+ sendUserEventTelemetry(win, event, message, options = {}) {
+ // Only send pings for non private browsing windows
+ if (
+ win &&
+ !lazy.PrivateBrowsingUtils.isBrowserPrivate(
+ win.ownerGlobal.gBrowser.selectedBrowser
+ )
+ ) {
+ this._sendPing({
+ message_id: message.id,
+ event,
+ event_context: options.value,
+ });
+ }
+ }
+
+ /**
+ * Inserts a message into the Protections Panel. The message is visible once
+ * and afterwards set in a collapsed state. It can be shown again using the
+ * info button in the panel header.
+ */
+ async insertProtectionPanelMessage(event) {
+ const win = event.target.ownerGlobal;
+ this.maybeInsertFTL(win);
+
+ const doc = event.target.ownerDocument;
+ const container = doc.getElementById("messaging-system-message-container");
+ const infoButton = doc.getElementById("protections-popup-info-button");
+ const panelContainer = doc.getElementById("protections-popup");
+ const toggleMessage = () => {
+ const learnMoreLink = doc.querySelector(
+ "#messaging-system-message-container .text-link"
+ );
+ if (learnMoreLink) {
+ container.toggleAttribute("disabled");
+ infoButton.toggleAttribute("checked");
+ panelContainer.toggleAttribute("infoMessageShowing");
+ learnMoreLink.disabled = !learnMoreLink.disabled;
+ }
+ };
+ if (!container.childElementCount) {
+ const message = await this._getMessages({
+ template: "protections_panel",
+ triggerId: "protectionsPanelOpen",
+ });
+ if (message) {
+ const messageEl = this._createHeroElement(win, doc, message);
+ container.appendChild(messageEl);
+ infoButton.addEventListener("click", toggleMessage);
+ this.sendUserEventTelemetry(win, "IMPRESSION", message);
+ }
+ }
+ // Message is collapsed by default. If it was never shown before we want
+ // to expand it
+ if (
+ !this.state.protectionPanelMessageSeen &&
+ container.hasAttribute("disabled")
+ ) {
+ toggleMessage();
+ }
+ // Save state that we displayed the message
+ if (!this.state.protectionPanelMessageSeen) {
+ Services.prefs.setBoolPref(PROTECTIONS_PANEL_INFOMSG_PREF, true);
+ this.state.protectionPanelMessageSeen = true;
+ }
+ // Collapse the message after the panel is hidden so we don't get the
+ // animation when opening the panel
+ panelContainer.addEventListener(
+ "popuphidden",
+ () => {
+ if (
+ this.state.protectionPanelMessageSeen &&
+ !container.hasAttribute("disabled")
+ ) {
+ toggleMessage();
+ }
+ },
+ {
+ once: true,
+ }
+ );
+ }
+
+ /**
+ * @param {object} [browser] MessageChannel target argument as a response to a
+ * user action. No message is shown if undefined.
+ * @param {object[]} messages Messages selected from devtools page
+ */
+ forceShowMessage(browser, messages) {
+ if (!browser) {
+ return;
+ }
+ const win = browser.ownerGlobal;
+ const doc = browser.ownerDocument;
+ this.removeMessages(win, WHATS_NEW_PANEL_SELECTOR);
+ this.renderMessages(win, doc, WHATS_NEW_PANEL_SELECTOR, {
+ force: true,
+ messages: Array.isArray(messages) ? messages : [messages],
+ });
+ win.PanelUI.panel.addEventListener("popuphidden", event =>
+ this.removeMessages(event.target.ownerGlobal, WHATS_NEW_PANEL_SELECTOR)
+ );
+ }
+}
+
+/**
+ * ToolbarPanelHub - singleton instance of _ToolbarPanelHub that can initiate
+ * message requests and render messages.
+ */
+const ToolbarPanelHub = new _ToolbarPanelHub();
+
+const EXPORTED_SYMBOLS = ["ToolbarPanelHub", "_ToolbarPanelHub"];