/* 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/. */ /* eslint-env mozilla/browser-window */ const DEFAULT_NEW_REPORT_ENDPOINT = "https://webcompat.com/issues/new"; import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs", }); const gDescriptionCheckRE = /\S/; class ViewState { #doc; #mainView; #reportSentView; #formElement; #reasonOptions; #randomizeReasons = false; currentTabURI; currentTabWebcompatDetailsPromise; constructor(doc) { this.#doc = doc; this.#mainView = doc.ownerGlobal.PanelMultiView.getViewNode( this.#doc, "report-broken-site-popup-mainView" ); this.#reportSentView = doc.ownerGlobal.PanelMultiView.getViewNode( this.#doc, "report-broken-site-popup-reportSentView" ); this.#formElement = doc.ownerGlobal.PanelMultiView.getViewNode( this.#doc, "report-broken-site-panel-form" ); ViewState.#cache.set(doc, this); this.#reasonOptions = Array.from( // Skip the first option ("choose reason"), since it always stays at the top this.reasonInput.querySelectorAll(`option:not(:first-of-type)`) ); } static #cache = new WeakMap(); static get(doc) { return ViewState.#cache.get(doc) ?? new ViewState(doc); } get mainPanelview() { return this.#mainView; } get reportSentPanelview() { return this.#reportSentView; } get urlInput() { return this.#mainView.querySelector("#report-broken-site-popup-url"); } get url() { return this.urlInput.value; } set url(spec) { this.urlInput.value = spec; } resetURLToCurrentTab() { const { currentURI } = this.#doc.ownerGlobal.gBrowser.selectedBrowser; this.currentTabURI = currentURI; this.urlInput.value = currentURI.spec; } get descriptionInput() { return this.#mainView.querySelector( "#report-broken-site-popup-description" ); } get description() { return this.descriptionInput.value; } set description(value) { this.descriptionInput.value = value; } static REASON_CHOICES_ID_PREFIX = "report-broken-site-popup-reason-"; get reasonInput() { return this.#mainView.querySelector("#report-broken-site-popup-reason"); } get reason() { const reason = this.reasonInput.selectedOptions[0].id.replace( ViewState.REASON_CHOICES_ID_PREFIX, "" ); return reason == "choose" ? undefined : reason; } set reason(value) { this.reasonInput.selectedIndex = this.#mainView.querySelector( `#${ViewState.REASON_CHOICES_ID_PREFIX}${value}` ).index; } #randomizeReasonsOrdering() { // As with QuickActionsLoaderDefault, we use the Normandy // randomizationId as our PRNG seed to ensure that the same // user should always get the same sequence. const seed = [...lazy.ClientEnvironment.randomizationId] .map(x => x.charCodeAt(0)) .reduce((sum, a) => sum + a, 0); const items = [...this.#reasonOptions]; this.#shuffleArray(items, seed); items[0].parentNode.append(...items); } #shuffleArray(array, seed) { // We use SplitMix as it is reputed to have a strong distribution of values. const prng = this.#getSplitMix32PRNG(seed); for (let i = array.length - 1; i > 0; i--) { const j = Math.floor(prng() * (i + 1)); [array[i], array[j]] = [array[j], array[i]]; } } // SplitMix32 is a splittable pseudorandom number generator (PRNG). // License: MIT (https://github.com/attilabuti/SimplexNoise) #getSplitMix32PRNG(a) { return () => { a |= 0; a = (a + 0x9e3779b9) | 0; var t = a ^ (a >>> 16); t = Math.imul(t, 0x21f0aaad); t = t ^ (t >>> 15); t = Math.imul(t, 0x735a2d97); return ((t = t ^ (t >>> 15)) >>> 0) / 4294967296; }; } #restoreReasonsOrdering() { this.#reasonOptions[0].parentNode.append(...this.#reasonOptions); } get form() { return this.#formElement; } reset() { this.currentTabWebcompatDetailsPromise = undefined; this.form.reset(); this.resetURLToCurrentTab(); } ensureReasonOrderingMatchesPref() { const randomizeReasons = this.#doc.ownerGlobal.ReportBrokenSite.randomizeReasons; if (randomizeReasons != this.#randomizeReasons) { if (randomizeReasons) { this.#randomizeReasonsOrdering(); } else { this.#restoreReasonsOrdering(); } this.#randomizeReasons = randomizeReasons; } } get isURLValid() { return this.urlInput.checkValidity(); } get isReasonValid() { const { reasonEnabled, reasonIsOptional } = this.#doc.ownerGlobal.ReportBrokenSite; return ( !reasonEnabled || reasonIsOptional || this.reasonInput.checkValidity() ); } get isDescriptionValid() { const { descriptionIsOptional } = this.#doc.ownerGlobal.ReportBrokenSite; return ( descriptionIsOptional || gDescriptionCheckRE.test(this.descriptionInput.value) ); } #focusMainViewElement(toFocus) { const panelview = this.#doc.ownerGlobal.PanelView.forNode(this.#mainView); panelview.selectedElement = toFocus; panelview.focusSelectedElement(); } focusFirstInvalidElement() { if (!this.isURLValid) { this.#focusMainViewElement(this.urlInput); } else if (!this.isReasonValid) { this.#focusMainViewElement(this.reasonInput); this.reasonInput.showPicker(); } else if (!this.isDescriptionValid) { this.#focusMainViewElement(this.descriptionInput); } } get sendMoreInfoLink() { return this.#mainView.querySelector( "#report-broken-site-popup-send-more-info-link" ); } get reasonLabelRequired() { return this.#mainView.querySelector( "#report-broken-site-popup-reason-label" ); } get reasonLabelOptional() { return this.#mainView.querySelector( "#report-broken-site-popup-reason-optional-label" ); } get descriptionLabelRequired() { return this.#mainView.querySelector( "#report-broken-site-popup-description-label" ); } get descriptionLabelOptional() { return this.#mainView.querySelector( "#report-broken-site-popup-description-optional-label" ); } get sendButton() { return this.#mainView.querySelector( "#report-broken-site-popup-send-button" ); } get cancelButton() { return this.#mainView.querySelector( "#report-broken-site-popup-cancel-button" ); } get mainView() { return this.#mainView; } get reportSentView() { return this.#reportSentView; } get okayButton() { return this.#reportSentView.querySelector( "#report-broken-site-popup-okay-button" ); } } export var ReportBrokenSite = new (class ReportBrokenSite { #newReportEndpoint = undefined; get sendMoreInfoEndpoint() { return this.#newReportEndpoint || DEFAULT_NEW_REPORT_ENDPOINT; } static WEBCOMPAT_REPORTER_CONFIG = { src: "desktop-reporter", utm_campaign: "report-broken-site", utm_source: "desktop-reporter", }; static DATAREPORTING_PREF = "datareporting.healthreport.uploadEnabled"; static REPORTER_ENABLED_PREF = "ui.new-webcompat-reporter.enabled"; static REASON_PREF = "ui.new-webcompat-reporter.reason-dropdown"; static REASON_PREF_VALUES = { 0: "disabled", 1: "optional", 2: "required", }; static REASON_RANDOMIZED_PREF = "ui.new-webcompat-reporter.reason-dropdown.randomized"; static SEND_MORE_INFO_PREF = "ui.new-webcompat-reporter.send-more-info-link"; static NEW_REPORT_ENDPOINT_PREF = "ui.new-webcompat-reporter.new-report-endpoint"; static REPORT_SITE_ISSUE_PREF = "extensions.webcompat-reporter.enabled"; static MAIN_PANELVIEW_ID = "report-broken-site-popup-mainView"; static SENT_PANELVIEW_ID = "report-broken-site-popup-reportSentView"; #_enabled = false; get enabled() { return this.#_enabled; } #reasonEnabled = false; #reasonIsOptional = true; #randomizeReasons = false; #descriptionIsOptional = true; #sendMoreInfoEnabled = true; get reasonEnabled() { return this.#reasonEnabled; } get reasonIsOptional() { return this.#reasonIsOptional; } get randomizeReasons() { return this.#randomizeReasons; } get descriptionIsOptional() { return this.#descriptionIsOptional; } constructor() { for (const [name, [pref, dflt]] of Object.entries({ dataReportingPref: [ReportBrokenSite.DATAREPORTING_PREF, false], reasonPref: [ReportBrokenSite.REASON_PREF, 0], reasonRandomizedPref: [ReportBrokenSite.REASON_RANDOMIZED_PREF, false], sendMoreInfoPref: [ReportBrokenSite.SEND_MORE_INFO_PREF, false], newReportEndpointPref: [ ReportBrokenSite.NEW_REPORT_ENDPOINT_PREF, DEFAULT_NEW_REPORT_ENDPOINT, ], enabledPref: [ReportBrokenSite.REPORTER_ENABLED_PREF, true], reportSiteIssueEnabledPref: [ ReportBrokenSite.REPORT_SITE_ISSUE_PREF, false, ], })) { XPCOMUtils.defineLazyPreferenceGetter( this, name, pref, dflt, this.#checkPrefs.bind(this) ); } this.#checkPrefs(); } canReportURI(uri) { return uri && (uri.schemeIs("http") || uri.schemeIs("https")); } #recordGleanEvent(name, extra) { Glean.webcompatreporting[name].record(extra); } updateParentMenu(event) { // We need to make sure that the Report Broken Site menu item // is disabled and/or hidden depending on the prefs/active tab URL // when our parent popups are shown, and if their tab's location // changes while they are open. const tabbrowser = event.target.ownerGlobal.gBrowser; this.enableOrDisableMenuitems(tabbrowser.selectedBrowser); tabbrowser.addTabsProgressListener(this); event.target.addEventListener( "popuphidden", () => { tabbrowser.removeTabsProgressListener(this); }, { once: true } ); } init(tabbrowser) { // Called in browser.js. const { ownerGlobal } = tabbrowser.selectedBrowser; const { document } = ownerGlobal; const state = ViewState.get(document); this.#initMainView(state); this.#initReportSentView(state); for (const id of ["menu_HelpPopup", "appMenu-popup"]) { document .getElementById(id) .addEventListener("popupshown", this.updateParentMenu.bind(this)); } state.mainPanelview.addEventListener("ViewShowing", ({ target }) => { const { selectedBrowser } = target.ownerGlobal.gBrowser; let source = "helpMenu"; switch (target.closest("panelmultiview")?.id) { case "appMenu-multiView": source = "hamburgerMenu"; break; case "protections-popup-multiView": source = "ETPShieldIconMenu"; break; } this.#onMainViewShown(source, selectedBrowser); }); // Make sure the URL input is focused when the main view pops up. state.mainPanelview.addEventListener("ViewShown", () => { const panelview = ownerGlobal.PanelView.forNode(state.mainPanelview); panelview.selectedElement = state.urlInput; panelview.focusSelectedElement(); }); // Make sure the Okay button is focused when the report sent view pops up. state.reportSentPanelview.addEventListener("ViewShown", () => { const panelview = ownerGlobal.PanelView.forNode( state.reportSentPanelview ); panelview.selectedElement = state.okayButton; panelview.focusSelectedElement(); }); } enableOrDisableMenuitems(selectedbrowser) { // Ensures that the various Report Broken Site menu items and // toolbar buttons are enabled/hidden when appropriate (and // also the Help menu's Report Site Issue item)/ const canReportUrl = this.canReportURI(selectedbrowser.currentURI); const { document } = selectedbrowser.ownerGlobal; const cmd = document.getElementById("cmd_reportBrokenSite"); if (this.enabled) { cmd.setAttribute("hidden", "false"); // see bug 805653 } else { cmd.setAttribute("hidden", "true"); } if (canReportUrl) { cmd.removeAttribute("disabled"); } else { cmd.setAttribute("disabled", "true"); } // Changes to the "hidden" and "disabled" state of the command aren't reliably // reflected on the main menu unless we open it twice, or do it manually. // (See bug 1864953). const mainmenuItem = document.getElementById("help_reportBrokenSite"); if (mainmenuItem) { mainmenuItem.hidden = !this.enabled; mainmenuItem.disabled = !canReportUrl; } // Report Site Issue is our older issue reporter, shown in the Help // menu on pre-release channels. We should hide it unless we're // disabled, at which point we should show it when available. const reportSiteIssue = document.getElementById("help_reportSiteIssue"); if (reportSiteIssue) { reportSiteIssue.hidden = this.enabled || !this.reportSiteIssueEnabledPref; reportSiteIssue.disabled = !canReportUrl; } } #checkPrefs(whichChanged) { // No breakage reports can be sent by Glean if it's disabled, so we also // disable the broken site reporter. We also have our own pref. this.#_enabled = Services.policies.isAllowed("feedbackCommands") && this.dataReportingPref && this.enabledPref; this.#reasonEnabled = this.reasonPref == 1 || this.reasonPref == 2; this.#reasonIsOptional = this.reasonPref == 1; if (!whichChanged || whichChanged == ReportBrokenSite.REASON_PREF) { const setting = ReportBrokenSite.REASON_PREF_VALUES[this.reasonPref]; this.#recordGleanEvent("reasonDropdown", { setting }); } this.#sendMoreInfoEnabled = this.sendMoreInfoPref; this.#newReportEndpoint = this.newReportEndpointPref; this.#randomizeReasons = this.reasonRandomizedPref; } #initMainView(state) { state.sendButton.addEventListener("command", () => { state.form.requestSubmit(); }); state.form.addEventListener("submit", async event => { event.preventDefault(); if (!state.form.checkValidity()) { state.focusFirstInvalidElement(); return; } const multiview = event.target.closest("panelmultiview"); this.#recordGleanEvent("send"); await this.#sendReportAsGleanPing(state); multiview.showSubView("report-broken-site-popup-reportSentView"); state.reset(); }); state.cancelButton.addEventListener("command", ({ target }) => { target.ownerGlobal.CustomizableUI.hidePanelForNode(target); state.reset(); }); state.sendMoreInfoLink.addEventListener("click", async event => { event.preventDefault(); const tabbrowser = event.target.ownerGlobal.gBrowser; this.#recordGleanEvent("sendMoreInfo"); event.target.ownerGlobal.CustomizableUI.hidePanelForNode(event.target); await this.#openWebCompatTab(tabbrowser); state.reset(); }); } #initReportSentView(state) { state.okayButton.addEventListener("command", ({ target }) => { target.ownerGlobal.CustomizableUI.hidePanelForNode(target); }); } async #onMainViewShown(source, selectedBrowser) { const { document } = selectedBrowser.ownerGlobal; let didReset = false; const state = ViewState.get(document); const uri = selectedBrowser.currentURI; if (!state.isURLValid && !state.isDescriptionValid) { state.reset(); didReset = true; } else if (!state.currentTabURI || !uri.equals(state.currentTabURI)) { state.reset(); didReset = true; } else if (!state.url) { state.resetURLToCurrentTab(); } const { sendMoreInfoLink } = state; const { sendMoreInfoEndpoint } = this; if (sendMoreInfoLink.href !== sendMoreInfoEndpoint) { sendMoreInfoLink.href = sendMoreInfoEndpoint; } sendMoreInfoLink.hidden = !this.#sendMoreInfoEnabled; state.reasonInput.hidden = !this.#reasonEnabled; state.reasonInput.required = this.#reasonEnabled && !this.#reasonIsOptional; state.ensureReasonOrderingMatchesPref(); state.reasonLabelRequired.hidden = !this.#reasonEnabled || this.#reasonIsOptional; state.reasonLabelOptional.hidden = !this.#reasonEnabled || !this.#reasonIsOptional; state.descriptionLabelRequired.hidden = this.#descriptionIsOptional; state.descriptionLabelOptional.hidden = !this.#descriptionIsOptional; this.#recordGleanEvent("opened", { source }); if (didReset || !state.currentTabWebcompatDetailsPromise) { state.currentTabWebcompatDetailsPromise = this.#queryActor( "GetWebCompatInfo", undefined, selectedBrowser ).catch(err => { console.error("Report Broken Site: unexpected error", err); }); } } async #queryActor(msg, params, browser) { const actor = browser.browsingContext.currentWindowGlobal.getActor("ReportBrokenSite"); return actor.sendQuery(msg, params); } async #loadTab(tabbrowser, url, triggeringPrincipal) { const tab = tabbrowser.addTab(url, { inBackground: false, triggeringPrincipal, }); const expectedBrowser = tabbrowser.getBrowserForTab(tab); return new Promise(resolve => { const listener = { onLocationChange(browser, webProgress, request, uri) { if ( browser == expectedBrowser && uri.spec == url && webProgress.isTopLevel ) { resolve(tab); tabbrowser.removeTabsProgressListener(listener); } }, }; tabbrowser.addTabsProgressListener(listener); }); } async #openWebCompatTab(tabbrowser) { const endpointUrl = this.sendMoreInfoEndpoint; const principal = Services.scriptSecurityManager.createNullPrincipal({}); const tab = await this.#loadTab(tabbrowser, endpointUrl, principal); const { document } = tabbrowser.selectedBrowser.ownerGlobal; const { description, reason, url, currentTabWebcompatDetailsPromise } = ViewState.get(document); return this.#queryActor( "SendDataToWebcompatCom", { reason, description, endpointUrl, reportUrl: url, reporterConfig: ReportBrokenSite.WEBCOMPAT_REPORTER_CONFIG, webcompatInfo: await currentTabWebcompatDetailsPromise, }, tab.linkedBrowser ).catch(err => { console.error("Report Broken Site: unexpected error", err); }); } async #sendReportAsGleanPing({ currentTabWebcompatDetailsPromise, description, reason, url, }) { const gBase = Glean.brokenSiteReport; const gTabInfo = Glean.brokenSiteReportTabInfo; const gAntitracking = Glean.brokenSiteReportTabInfoAntitracking; const gFrameworks = Glean.brokenSiteReportTabInfoFrameworks; const gApp = Glean.brokenSiteReportBrowserInfoApp; const gGraphics = Glean.brokenSiteReportBrowserInfoGraphics; const gPrefs = Glean.brokenSiteReportBrowserInfoPrefs; const gSystem = Glean.brokenSiteReportBrowserInfoSystem; if (reason) { gBase.breakageCategory.set(reason); } gBase.description.set(description); gBase.url.set(url); const details = await currentTabWebcompatDetailsPromise; if (!details) { GleanPings.brokenSiteReport.submit(); return; } const { antitracking, browser, devicePixelRatio, frameworks, languages, userAgent, } = details; gTabInfo.languages.set(languages); gTabInfo.useragentString.set(userAgent); gGraphics.devicePixelRatio.set(devicePixelRatio); for (const [name, value] of Object.entries(antitracking)) { gAntitracking[name].set(value); } for (const [name, value] of Object.entries(frameworks)) { gFrameworks[name].set(value); } const { app, graphics, locales, platform, prefs, security } = browser; gApp.defaultLocales.set(locales); gApp.defaultUseragentString.set(app.defaultUserAgent); const { fissionEnabled, isTablet, memoryMB } = platform; gApp.fissionEnabled.set(fissionEnabled); gSystem.isTablet.set(isTablet ?? false); gSystem.memory.set(memoryMB); gPrefs.cookieBehavior.set(prefs["network.cookie.cookieBehavior"]); gPrefs.forcedAcceleratedLayers.set( prefs["layers.acceleration.force-enabled"] ); gPrefs.globalPrivacyControlEnabled.set( prefs["privacy.globalprivacycontrol.enabled"] ); gPrefs.installtriggerEnabled.set( prefs["extensions.InstallTrigger.enabled"] ); gPrefs.opaqueResponseBlocking.set(prefs["browser.opaqueResponseBlocking"]); gPrefs.resistFingerprintingEnabled.set( prefs["privacy.resistFingerprinting"] ); gPrefs.softwareWebrender.set(prefs["gfx.webrender.software"]); if (security) { for (const [name, value] of Object.entries(security)) { if (value?.length) { Glean.brokenSiteReportBrowserInfoSecurity[name].set(value); } } } const { devices, drivers, features, hasTouchScreen, monitors } = graphics; gGraphics.devicesJson.set(JSON.stringify(devices)); gGraphics.driversJson.set(JSON.stringify(drivers)); gGraphics.featuresJson.set(JSON.stringify(features)); gGraphics.hasTouchScreen.set(hasTouchScreen); gGraphics.monitorsJson.set(JSON.stringify(monitors)); GleanPings.brokenSiteReport.submit(); } open(event) { const { target } = event.sourceEvent; const { selectedBrowser } = target.ownerGlobal.gBrowser; const { ownerGlobal } = selectedBrowser; const { document } = ownerGlobal; switch (target.id) { case "appMenu-report-broken-site-button": ownerGlobal.PanelUI.showSubView( ReportBrokenSite.MAIN_PANELVIEW_ID, target ); break; case "protections-popup-report-broken-site-button": document .getElementById("protections-popup-multiView") .showSubView(ReportBrokenSite.MAIN_PANELVIEW_ID); break; case "help_reportBrokenSite": { // hide the hamburger menu first, as we overlap with it. const appMenuPopup = document.getElementById("appMenu-popup"); appMenuPopup?.hidePopup(); ownerGlobal.PanelUI.showSubView( ReportBrokenSite.MAIN_PANELVIEW_ID, ownerGlobal.PanelUI.menuButton ); break; } } } })();