tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

ReportBrokenSite.sys.mjs (28220B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 const DEFAULT_NEW_REPORT_ENDPOINT = "https://webcompat.com/issues/new";
      6 
      7 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
      8 
      9 const lazy = {};
     10 
     11 ChromeUtils.defineESModuleGetters(lazy, {
     12  ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs",
     13 });
     14 
     15 const gDescriptionCheckRE = /\S/;
     16 
     17 export class ViewState {
     18  #doc;
     19  #mainView;
     20  #previewView;
     21  #reportSentView;
     22  #formElement;
     23  #reasonOptions;
     24  #randomizeReasons = false;
     25 
     26  currentTabURI;
     27  currentTabWebcompatDetailsPromise;
     28 
     29  constructor(doc) {
     30    this.#doc = doc;
     31    this.#mainView = doc.ownerGlobal.PanelMultiView.getViewNode(
     32      this.#doc,
     33      "report-broken-site-popup-mainView"
     34    );
     35    this.#previewView = doc.ownerGlobal.PanelMultiView.getViewNode(
     36      this.#doc,
     37      "report-broken-site-popup-previewView"
     38    );
     39    this.#reportSentView = doc.ownerGlobal.PanelMultiView.getViewNode(
     40      this.#doc,
     41      "report-broken-site-popup-reportSentView"
     42    );
     43    this.#formElement = doc.ownerGlobal.PanelMultiView.getViewNode(
     44      this.#doc,
     45      "report-broken-site-panel-form"
     46    );
     47    ViewState.#cache.set(doc, this);
     48 
     49    this.#reasonOptions = Array.from(
     50      // Skip the first option ("choose reason"), since it always stays at the top
     51      this.reasonInput.querySelectorAll(`option:not(:first-of-type)`)
     52    );
     53  }
     54 
     55  static #cache = new WeakMap();
     56  static get(doc) {
     57    return ViewState.#cache.get(doc) ?? new ViewState(doc);
     58  }
     59 
     60  get mainPanelview() {
     61    return this.#mainView;
     62  }
     63 
     64  get previewPanelview() {
     65    return this.#mainView;
     66  }
     67 
     68  get reportSentPanelview() {
     69    return this.#reportSentView;
     70  }
     71 
     72  get urlInput() {
     73    return this.#mainView.querySelector("#report-broken-site-popup-url");
     74  }
     75 
     76  get url() {
     77    return this.urlInput.value;
     78  }
     79 
     80  set url(spec) {
     81    this.urlInput.value = spec;
     82  }
     83 
     84  resetURLToCurrentTab() {
     85    const { currentURI } = this.#doc.ownerGlobal.gBrowser.selectedBrowser;
     86    this.currentTabURI = currentURI;
     87    this.urlInput.value = currentURI.spec;
     88  }
     89 
     90  get descriptionInput() {
     91    return this.#mainView.querySelector(
     92      "#report-broken-site-popup-description"
     93    );
     94  }
     95 
     96  get description() {
     97    return this.descriptionInput.value;
     98  }
     99 
    100  set description(value) {
    101    this.descriptionInput.value = value;
    102  }
    103 
    104  static REASON_CHOICES_ID_PREFIX = "report-broken-site-popup-reason-";
    105 
    106  get blockedTrackersCheckbox() {
    107    return this.#mainView.querySelector(
    108      "#report-broken-site-popup-blocked-trackers-checkbox"
    109    );
    110  }
    111 
    112  get reasonInput() {
    113    return this.#mainView.querySelector("#report-broken-site-popup-reason");
    114  }
    115 
    116  get reason() {
    117    const reason = this.reasonInput.selectedOptions[0].id.replace(
    118      ViewState.REASON_CHOICES_ID_PREFIX,
    119      ""
    120    );
    121    return reason == "choose" ? undefined : reason;
    122  }
    123 
    124  get reasonText() {
    125    const { reasonInput } = this;
    126    if (!reasonInput.selectedIndex) {
    127      return "";
    128    }
    129    return reasonInput.selectedOptions[0]?.label;
    130  }
    131 
    132  set reason(value) {
    133    this.reasonInput.selectedIndex = this.#mainView.querySelector(
    134      `#${ViewState.REASON_CHOICES_ID_PREFIX}${value}`
    135    ).index;
    136  }
    137 
    138  #randomizeReasonsOrdering() {
    139    // As with QuickActionsLoaderDefault, we use the Normandy
    140    // randomizationId as our PRNG seed to ensure that the same
    141    // user should always get the same sequence.
    142    const seed = [...lazy.ClientEnvironment.randomizationId]
    143      .map(x => x.charCodeAt(0))
    144      .reduce((sum, a) => sum + a, 0);
    145 
    146    const items = [...this.#reasonOptions];
    147    this.#shuffleArray(items, seed);
    148    items[0].parentNode.append(...items);
    149  }
    150 
    151  #shuffleArray(array, seed) {
    152    // We use SplitMix as it is reputed to have a strong distribution of values.
    153    const prng = this.#getSplitMix32PRNG(seed);
    154    for (let i = array.length - 1; i > 0; i--) {
    155      const j = Math.floor(prng() * (i + 1));
    156      [array[i], array[j]] = [array[j], array[i]];
    157    }
    158  }
    159 
    160  // SplitMix32 is a splittable pseudorandom number generator (PRNG).
    161  // License: MIT (https://github.com/attilabuti/SimplexNoise)
    162  #getSplitMix32PRNG(a) {
    163    return () => {
    164      a |= 0;
    165      a = (a + 0x9e3779b9) | 0;
    166      var t = a ^ (a >>> 16);
    167      t = Math.imul(t, 0x21f0aaad);
    168      t = t ^ (t >>> 15);
    169      t = Math.imul(t, 0x735a2d97);
    170      return ((t = t ^ (t >>> 15)) >>> 0) / 4294967296;
    171    };
    172  }
    173 
    174  #restoreReasonsOrdering() {
    175    this.#reasonOptions[0].parentNode.append(...this.#reasonOptions);
    176  }
    177 
    178  get form() {
    179    return this.#formElement;
    180  }
    181 
    182  reset() {
    183    this.currentTabWebcompatDetailsPromise = undefined;
    184    this.form.reset();
    185    this.blockedTrackersCheckbox.checked = false;
    186    delete this.cachedPreviewData;
    187 
    188    this.resetURLToCurrentTab();
    189  }
    190 
    191  ensureReasonOrderingMatchesPref() {
    192    const { randomizeReasons } = ReportBrokenSite;
    193    if (randomizeReasons != this.#randomizeReasons) {
    194      if (randomizeReasons) {
    195        this.#randomizeReasonsOrdering();
    196      } else {
    197        this.#restoreReasonsOrdering();
    198      }
    199      this.#randomizeReasons = randomizeReasons;
    200    }
    201  }
    202 
    203  get isURLValid() {
    204    return this.urlInput.checkValidity();
    205  }
    206 
    207  get isReasonValid() {
    208    const { reasonEnabled, reasonIsOptional } = ReportBrokenSite;
    209    return (
    210      !reasonEnabled || reasonIsOptional || this.reasonInput.checkValidity()
    211    );
    212  }
    213 
    214  get isDescriptionValid() {
    215    return (
    216      ReportBrokenSite.descriptionIsOptional ||
    217      gDescriptionCheckRE.test(this.descriptionInput.value)
    218    );
    219  }
    220 
    221  createElement(name) {
    222    return this.#doc.createElement(name);
    223  }
    224 
    225  #focusMainViewElement(toFocus) {
    226    const panelview = this.#doc.ownerGlobal.PanelView.forNode(this.#mainView);
    227    panelview.selectedElement = toFocus;
    228    panelview.focusSelectedElement();
    229  }
    230 
    231  focusFirstInvalidElement() {
    232    if (!this.isURLValid) {
    233      this.#focusMainViewElement(this.urlInput);
    234    } else if (!this.isReasonValid) {
    235      this.#focusMainViewElement(this.reasonInput);
    236      this.reasonInput.showPicker();
    237    } else if (!this.isDescriptionValid) {
    238      this.#focusMainViewElement(this.descriptionInput);
    239    }
    240  }
    241 
    242  get learnMoreLink() {
    243    return this.#mainView.querySelector(
    244      "#report-broken-site-popup-learn-more-link"
    245    );
    246  }
    247 
    248  get sendMoreInfoLink() {
    249    return this.#mainView.querySelector(
    250      "#report-broken-site-popup-send-more-info-link"
    251    );
    252  }
    253 
    254  get reasonLabelRequired() {
    255    return this.#mainView.querySelector(
    256      "#report-broken-site-popup-reason-label"
    257    );
    258  }
    259 
    260  get reasonLabelOptional() {
    261    return this.#mainView.querySelector(
    262      "#report-broken-site-popup-reason-optional-label"
    263    );
    264  }
    265 
    266  get descriptionLabelRequired() {
    267    return this.#mainView.querySelector(
    268      "#report-broken-site-popup-description-label"
    269    );
    270  }
    271 
    272  get descriptionLabelOptional() {
    273    return this.#mainView.querySelector(
    274      "#report-broken-site-popup-description-optional-label"
    275    );
    276  }
    277 
    278  get sendButton() {
    279    return this.#mainView.querySelector(
    280      "#report-broken-site-popup-send-button"
    281    );
    282  }
    283 
    284  get cancelButton() {
    285    return this.#mainView.querySelector(
    286      "#report-broken-site-popup-cancel-button"
    287    );
    288  }
    289 
    290  get mainView() {
    291    return this.#mainView;
    292  }
    293 
    294  get reportSentView() {
    295    return this.#reportSentView;
    296  }
    297 
    298  get okayButton() {
    299    return this.#reportSentView.querySelector(
    300      "#report-broken-site-popup-okay-button"
    301    );
    302  }
    303 
    304  get previewCancelButton() {
    305    return this.#previewView.querySelector(
    306      "#report-broken-site-popup-preview-cancel-button"
    307    );
    308  }
    309 
    310  get previewSendButton() {
    311    return this.#previewView.querySelector(
    312      "#report-broken-site-popup-preview-send-button"
    313    );
    314  }
    315 
    316  get previewBox() {
    317    return this.#previewView.querySelector(
    318      "#report-broken-site-panel-preview-items"
    319    );
    320  }
    321 
    322  get previewButton() {
    323    return this.#mainView.querySelector(
    324      "#report-broken-site-popup-preview-button"
    325    );
    326  }
    327 }
    328 
    329 export var ReportBrokenSite = new (class ReportBrokenSite {
    330  #newReportEndpoint = undefined;
    331 
    332  get sendMoreInfoEndpoint() {
    333    return this.#newReportEndpoint || DEFAULT_NEW_REPORT_ENDPOINT;
    334  }
    335 
    336  static WEBCOMPAT_REPORTER_CONFIG = {
    337    src: "desktop-reporter",
    338    utm_campaign: "report-broken-site",
    339    utm_source: "desktop-reporter",
    340  };
    341 
    342  static DATAREPORTING_PREF = "datareporting.healthreport.uploadEnabled";
    343  static REPORTER_ENABLED_PREF = "ui.new-webcompat-reporter.enabled";
    344 
    345  static REASON_PREF = "ui.new-webcompat-reporter.reason-dropdown";
    346  static REASON_PREF_VALUES = {
    347    0: "disabled",
    348    1: "optional",
    349    2: "required",
    350  };
    351  static REASON_RANDOMIZED_PREF =
    352    "ui.new-webcompat-reporter.reason-dropdown.randomized";
    353  static SEND_MORE_INFO_PREF = "ui.new-webcompat-reporter.send-more-info-link";
    354  static NEW_REPORT_ENDPOINT_PREF =
    355    "ui.new-webcompat-reporter.new-report-endpoint";
    356 
    357  static MAIN_PANELVIEW_ID = "report-broken-site-popup-mainView";
    358  static SENT_PANELVIEW_ID = "report-broken-site-popup-reportSentView";
    359  static PREVIEW_PANELVIEW_ID = "report-broken-site-popup-previewView";
    360 
    361  #_enabled = false;
    362  get enabled() {
    363    return this.#_enabled;
    364  }
    365 
    366  #reasonEnabled = false;
    367  #reasonIsOptional = true;
    368  #randomizeReasons = false;
    369  #descriptionIsOptional = true;
    370  #sendMoreInfoEnabled = true;
    371 
    372  get reasonEnabled() {
    373    return this.#reasonEnabled;
    374  }
    375 
    376  get reasonIsOptional() {
    377    return this.#reasonIsOptional;
    378  }
    379 
    380  get randomizeReasons() {
    381    return this.#randomizeReasons;
    382  }
    383 
    384  get descriptionIsOptional() {
    385    return this.#descriptionIsOptional;
    386  }
    387 
    388  constructor() {
    389    for (const [name, [pref, dflt]] of Object.entries({
    390      dataReportingPref: [ReportBrokenSite.DATAREPORTING_PREF, false],
    391      reasonPref: [ReportBrokenSite.REASON_PREF, 0],
    392      reasonRandomizedPref: [ReportBrokenSite.REASON_RANDOMIZED_PREF, false],
    393      sendMoreInfoPref: [ReportBrokenSite.SEND_MORE_INFO_PREF, false],
    394      newReportEndpointPref: [
    395        ReportBrokenSite.NEW_REPORT_ENDPOINT_PREF,
    396        DEFAULT_NEW_REPORT_ENDPOINT,
    397      ],
    398      enabledPref: [ReportBrokenSite.REPORTER_ENABLED_PREF, true],
    399    })) {
    400      XPCOMUtils.defineLazyPreferenceGetter(
    401        this,
    402        name,
    403        pref,
    404        dflt,
    405        this.#checkPrefs.bind(this)
    406      );
    407    }
    408    this.#checkPrefs();
    409  }
    410 
    411  canReportURI(uri) {
    412    return uri && (uri.schemeIs("http") || uri.schemeIs("https"));
    413  }
    414 
    415  #recordGleanEvent(name, extra) {
    416    Glean.webcompatreporting[name].record(extra);
    417  }
    418 
    419  updateParentMenu(event) {
    420    // We need to make sure that the Report Broken Site menu item
    421    // is disabled if the tab's location changes to a non-reportable
    422    // one while the menu is open.
    423    const tabbrowser = event.target.ownerGlobal.gBrowser;
    424    this.enableOrDisableMenuitems(tabbrowser.selectedBrowser);
    425 
    426    tabbrowser.addTabsProgressListener(this);
    427    event.target.addEventListener(
    428      "popuphidden",
    429      () => {
    430        tabbrowser.removeTabsProgressListener(this);
    431      },
    432      { once: true }
    433    );
    434  }
    435 
    436  init(win) {
    437    // Called in browser-init.js via the category manager registration
    438    // in BrowserComponents.manifest
    439    const { document } = win;
    440 
    441    const state = ViewState.get(document);
    442 
    443    this.#initMainView(state);
    444    this.#initPreviewView(state);
    445    this.#initReportSentView(state);
    446 
    447    for (const id of ["menu_HelpPopup", "appMenu-popup"]) {
    448      document
    449        .getElementById(id)
    450        .addEventListener("popupshown", this.updateParentMenu.bind(this));
    451    }
    452 
    453    state.mainPanelview.addEventListener("ViewShowing", ({ target }) => {
    454      const { selectedBrowser } = target.ownerGlobal.gBrowser;
    455      let source = "helpMenu";
    456      switch (target.closest("panelmultiview")?.id) {
    457        case "appMenu-multiView":
    458          source = "hamburgerMenu";
    459          break;
    460        case "protections-popup-multiView":
    461          source = "ETPShieldIconMenu";
    462          break;
    463      }
    464      this.#onMainViewShown(source, selectedBrowser);
    465    });
    466 
    467    // Make sure the URL input is focused when the main view pops up.
    468    state.mainPanelview.addEventListener("ViewShown", () => {
    469      const panelview = win.PanelView.forNode(state.mainPanelview);
    470      panelview.selectedElement = state.urlInput;
    471      panelview.focusSelectedElement();
    472      Services.focus
    473        .getFocusedElementForWindow(win, true, {})
    474        ?.setSelectionRange(0, 0);
    475    });
    476 
    477    // Make sure the Okay button is focused when the report sent view pops up.
    478    state.reportSentPanelview.addEventListener("ViewShown", () => {
    479      const panelview = win.PanelView.forNode(state.reportSentPanelview);
    480      panelview.selectedElement = state.okayButton;
    481      panelview.focusSelectedElement();
    482    });
    483 
    484    win.document
    485      .getElementById("cmd_reportBrokenSite")
    486      .addEventListener("command", e => {
    487        if (this.enabled) {
    488          this.open(e);
    489        } else {
    490          const tabbrowser = e.target.ownerGlobal.gBrowser;
    491          state.resetURLToCurrentTab();
    492          this.promiseWebCompatInfo(state, tabbrowser.selectedBrowser);
    493          this.#openWebCompatTab(tabbrowser)
    494            .catch(err => {
    495              console.error("Report Broken Site: unexpected error", err);
    496            })
    497            .finally(() => {
    498              state.reset();
    499            });
    500        }
    501      });
    502  }
    503 
    504  enableOrDisableMenuitems(selectedbrowser) {
    505    // Ensures that the various Report Broken Site menu items and
    506    // toolbar buttons are enabled/hidden when appropriate.
    507 
    508    const canReportUrl = this.canReportURI(selectedbrowser.currentURI);
    509 
    510    const { document } = selectedbrowser.ownerGlobal;
    511 
    512    // Altering the disabled attribute on the command does not propagate
    513    // the change to the related menuitems (see bug 805653), so we change them all.
    514    const cmd = document.getElementById("cmd_reportBrokenSite");
    515    // Hide the items in base-browser. tor-browser#43903.
    516    const allowedByPolicy = false;
    517    cmd.toggleAttribute("hidden", !allowedByPolicy);
    518    const app = document.ownerGlobal.PanelMultiView.getViewNode(
    519      document,
    520      "appMenu-report-broken-site-button"
    521    );
    522    // Note that this element does not exist until the protections popup is actually opened.
    523    const prot = document.getElementById(
    524      "protections-popup-report-broken-site-button"
    525    );
    526    if (canReportUrl) {
    527      cmd.removeAttribute("disabled");
    528      app.removeAttribute("disabled");
    529      prot?.removeAttribute("disabled");
    530    } else {
    531      cmd.setAttribute("disabled", "true");
    532      app.setAttribute("disabled", "true");
    533      prot?.setAttribute("disabled", "true");
    534    }
    535 
    536    // Changes to the "hidden" and "disabled" state of the command aren't reliably
    537    // reflected on the main menu unless we open it twice, or do it manually.
    538    // (See bug 1864953).
    539    const mainmenuItem = document.getElementById("help_reportBrokenSite");
    540    if (mainmenuItem) {
    541      mainmenuItem.hidden = !allowedByPolicy;
    542      mainmenuItem.disabled = !canReportUrl;
    543    }
    544  }
    545 
    546  #checkPrefs(whichChanged) {
    547    // No breakage reports can be sent by Glean if it's disabled, so we also
    548    // disable the broken site reporter. We also have our own pref.
    549    this.#_enabled =
    550      Services.policies.isAllowed("feedbackCommands") &&
    551      this.dataReportingPref &&
    552      this.enabledPref;
    553 
    554    this.#reasonEnabled = this.reasonPref == 1 || this.reasonPref == 2;
    555    this.#reasonIsOptional = this.reasonPref == 1;
    556    if (!whichChanged || whichChanged == ReportBrokenSite.REASON_PREF) {
    557      const setting = ReportBrokenSite.REASON_PREF_VALUES[this.reasonPref];
    558      this.#recordGleanEvent("reasonDropdown", { setting });
    559    }
    560 
    561    this.#sendMoreInfoEnabled = this.sendMoreInfoPref;
    562    this.#newReportEndpoint = this.newReportEndpointPref;
    563 
    564    this.#randomizeReasons = this.reasonRandomizedPref;
    565  }
    566 
    567  #initMainView(state) {
    568    state.sendButton.addEventListener("command", () => {
    569      state.form.requestSubmit();
    570    });
    571 
    572    state.form.addEventListener("submit", async event => {
    573      event.preventDefault();
    574      if (!state.form.checkValidity()) {
    575        state.focusFirstInvalidElement();
    576        return;
    577      }
    578      const multiview = event.target.closest("panelmultiview");
    579      this.#recordGleanEvent("send", {
    580        sent_with_blocked_trackers: !!state.blockedTrackersCheckbox.checked,
    581      });
    582      await this.#sendReportAsGleanPing(state);
    583      multiview.showSubView("report-broken-site-popup-reportSentView");
    584      state.reset();
    585    });
    586 
    587    state.cancelButton.addEventListener("command", ({ target }) => {
    588      target.ownerGlobal.CustomizableUI.hidePanelForNode(target);
    589      state.reset();
    590    });
    591 
    592    state.sendMoreInfoLink.addEventListener("click", async event => {
    593      event.preventDefault();
    594      const tabbrowser = event.target.ownerGlobal.gBrowser;
    595      this.#recordGleanEvent("sendMoreInfo");
    596      event.target.ownerGlobal.CustomizableUI.hidePanelForNode(event.target);
    597      await this.#openWebCompatTab(tabbrowser);
    598      state.reset();
    599    });
    600 
    601    state.learnMoreLink.addEventListener("click", async event => {
    602      this.#recordGleanEvent("learnMore");
    603      event.target.ownerGlobal.requestAnimationFrame(() => {
    604        event.target.ownerGlobal.CustomizableUI.hidePanelForNode(event.target);
    605      });
    606    });
    607 
    608    state.previewButton.addEventListener("click", event => {
    609      state.currentTabWebcompatDetailsPromise
    610        ?.catch(_ => {})
    611        .then(info => {
    612          this.generatePreviewMarkup(state, info);
    613 
    614          // Update the live data on the preview which the user can edit in the reporter.
    615          const { description, previewBox, reasonText } = state;
    616          if (state.cachedPreviewData) {
    617            state.cachedPreviewData.basic.description = description;
    618            state.cachedPreviewData.basic.reason = reasonText;
    619          }
    620          previewBox.querySelector(
    621            ".preview_description"
    622          ).nextSibling.innerText = JSON.stringify(description);
    623          previewBox.querySelector(".preview_reason").nextSibling.innerText =
    624            JSON.stringify(reasonText ?? "");
    625 
    626          const multiview = event.target.closest("panelmultiview");
    627          multiview.showSubView(
    628            ReportBrokenSite.PREVIEW_PANELVIEW_ID,
    629            event.target
    630          );
    631          this.#recordGleanEvent("previewed");
    632        });
    633    });
    634  }
    635 
    636  #initPreviewView(state) {
    637    state.previewSendButton.addEventListener("command", event => {
    638      // If the user has not entered a reason yet, then the form's validity
    639      // check will bring up the reason dropdown, despite it being out of view
    640      // (since we're looking at the preview panel, not the main one). This is
    641      // confusing, so we instead go back to the main view first if there is a
    642      // validity check failure (we also have to be careful to avoid possibly
    643      // racing with the user if they close the popup during this sequence, so
    644      // we don't leak any event listeners and world with them).
    645      if (!state.form.checkValidity()) {
    646        const view = event.target.closest("panelview").panelMultiView;
    647        const { document } = event.target.ownerGlobal;
    648        const listener = event => {
    649          document.removeEventListener("popuphiding", listener);
    650          view.removeEventListener("ViewShown", listener);
    651          if (event.type == "ViewShown") {
    652            state.form.requestSubmit();
    653          }
    654        };
    655        document.addEventListener("popuphiding", listener);
    656        view.addEventListener("ViewShown", listener);
    657        view.goBack();
    658      } else {
    659        state.form.requestSubmit();
    660      }
    661    });
    662 
    663    state.previewCancelButton.addEventListener("command", ({ target }) => {
    664      target.ownerGlobal.CustomizableUI.hidePanelForNode(target);
    665      state.reset();
    666    });
    667  }
    668 
    669  #initReportSentView(state) {
    670    state.okayButton.addEventListener("command", ({ target }) => {
    671      target.ownerGlobal.CustomizableUI.hidePanelForNode(target);
    672    });
    673  }
    674 
    675  async #onMainViewShown(source, selectedBrowser) {
    676    const { document } = selectedBrowser.ownerGlobal;
    677 
    678    let didReset = false;
    679    const state = ViewState.get(document);
    680    const uri = selectedBrowser.currentURI;
    681    if (!state.isURLValid && !state.isDescriptionValid) {
    682      state.reset();
    683      didReset = true;
    684    } else if (!state.currentTabURI || !uri.equals(state.currentTabURI)) {
    685      state.reset();
    686      didReset = true;
    687    } else if (!state.url) {
    688      state.resetURLToCurrentTab();
    689    }
    690 
    691    const { sendMoreInfoLink } = state;
    692    const { sendMoreInfoEndpoint } = this;
    693    if (sendMoreInfoLink.href !== sendMoreInfoEndpoint) {
    694      sendMoreInfoLink.href = sendMoreInfoEndpoint;
    695    }
    696    sendMoreInfoLink.hidden = !this.#sendMoreInfoEnabled;
    697 
    698    state.reasonInput.hidden = !this.#reasonEnabled;
    699    state.reasonInput.required = this.#reasonEnabled && !this.#reasonIsOptional;
    700 
    701    state.ensureReasonOrderingMatchesPref();
    702 
    703    state.reasonLabelRequired.hidden =
    704      !this.#reasonEnabled || this.#reasonIsOptional;
    705    state.reasonLabelOptional.hidden =
    706      !this.#reasonEnabled || !this.#reasonIsOptional;
    707 
    708    state.descriptionLabelRequired.hidden = this.#descriptionIsOptional;
    709    state.descriptionLabelOptional.hidden = !this.#descriptionIsOptional;
    710 
    711    this.#recordGleanEvent("opened", { source });
    712 
    713    if (didReset || !state.currentTabWebcompatDetailsPromise) {
    714      this.promiseWebCompatInfo(state, selectedBrowser);
    715    }
    716  }
    717 
    718  promiseWebCompatInfo(state, selectedBrowser) {
    719    state.currentTabWebcompatDetailsPromise = this.#queryActor(
    720      "GetBrokenSiteReport",
    721      undefined,
    722      selectedBrowser
    723    ).catch(err => {
    724      console.error("Report Broken Site: unexpected error", err);
    725      state.currentTabWebcompatDetailsPromise = undefined;
    726    });
    727  }
    728 
    729  cachePreviewData(state, brokenSiteReportData) {
    730    const { blockedTrackersCheckbox, description, reasonText, url } = state;
    731 
    732    const previewData = Object.assign({
    733      basic: {
    734        description,
    735        reason: reasonText,
    736        url,
    737      },
    738    });
    739 
    740    if (brokenSiteReportData) {
    741      for (const [category, values] of Object.entries(brokenSiteReportData)) {
    742        previewData[category] = Object.fromEntries(
    743          Object.entries(values)
    744            .filter(([_, { do_not_preview }]) => !do_not_preview)
    745            .map(([name, { value }]) => [name, value])
    746        );
    747      }
    748    }
    749 
    750    if (!blockedTrackersCheckbox.checked && previewData.antitracking) {
    751      delete previewData.antitracking.blockedOrigins;
    752    }
    753 
    754    state.cachedPreviewData = previewData;
    755    return previewData;
    756  }
    757 
    758  generatePreviewMarkup(state, reportData) {
    759    // If we have already cached preview data, we have already generated the markup as well.
    760    if (this.cachedPreviewData) {
    761      return;
    762    }
    763    const previewData = this.cachePreviewData(state, reportData);
    764    const preview = state.previewBox;
    765    preview.innerHTML = "";
    766    for (const [name, value] of Object.entries(previewData)) {
    767      const details = state.createElement("details");
    768 
    769      const summary = state.createElement("summary");
    770      summary.innerText = name;
    771      summary.dataset.capturesFocus = "true";
    772      details.appendChild(summary);
    773 
    774      const info = state.createElement("div");
    775      info.className = "data";
    776      for (const [k, v] of Object.entries(value)) {
    777        const div = state.createElement("div");
    778        div.className = "entry";
    779        const span_name = state.createElement("span");
    780        const span_value = state.createElement("span");
    781        span_name.className = `preview_${k}`;
    782        span_name.innerText = `${k}:`;
    783        // Add some extra word-wrapping opportunities to the data by adding spaces,
    784        // so users don't have to horizontally scroll as much.
    785        span_value.innerText = JSON.stringify(v)?.replace(/[,:]/g, "$& ") ?? "";
    786        div.append(span_name, span_value);
    787        info.appendChild(div);
    788      }
    789      details.appendChild(info);
    790 
    791      preview.appendChild(details);
    792    }
    793    const first = preview.querySelector("details");
    794    if (first) {
    795      first.setAttribute("open", "");
    796    }
    797  }
    798 
    799  async #queryActor(msg, params, browser) {
    800    const actor =
    801      browser.browsingContext.currentWindowGlobal.getActor("ReportBrokenSite");
    802    return actor.sendQuery(msg, params);
    803  }
    804 
    805  async #loadTab(tabbrowser, url, triggeringPrincipal) {
    806    const tab = tabbrowser.addTab(url, {
    807      inBackground: false,
    808      triggeringPrincipal,
    809    });
    810    const expectedBrowser = tabbrowser.getBrowserForTab(tab);
    811    return new Promise(resolve => {
    812      const listener = {
    813        onLocationChange(browser, webProgress, request, uri) {
    814          if (
    815            browser == expectedBrowser &&
    816            uri.spec == url &&
    817            webProgress.isTopLevel
    818          ) {
    819            resolve(tab);
    820            tabbrowser.removeTabsProgressListener(listener);
    821          }
    822        },
    823      };
    824      tabbrowser.addTabsProgressListener(listener);
    825    });
    826  }
    827 
    828  async #openWebCompatTab(tabbrowser) {
    829    const endpointUrl = this.sendMoreInfoEndpoint;
    830    const principal = Services.scriptSecurityManager.createNullPrincipal({});
    831    const tab = await this.#loadTab(tabbrowser, endpointUrl, principal);
    832    const { document } = tabbrowser.selectedBrowser.ownerGlobal;
    833    const { description, reason, url, currentTabWebcompatDetailsPromise } =
    834      ViewState.get(document);
    835 
    836    return this.#queryActor(
    837      "SendDataToWebcompatCom",
    838      {
    839        reason,
    840        description,
    841        endpointUrl,
    842        reportUrl: url,
    843        reporterConfig: ReportBrokenSite.WEBCOMPAT_REPORTER_CONFIG,
    844        webcompatInfo: await currentTabWebcompatDetailsPromise,
    845      },
    846      tab.linkedBrowser
    847    ).catch(err => {
    848      console.error("Report Broken Site: unexpected error", err);
    849    });
    850  }
    851 
    852  async #sendReportAsGleanPing({
    853    blockedTrackersCheckbox,
    854    currentTabWebcompatDetailsPromise,
    855    description,
    856    reason,
    857    url,
    858  }) {
    859    const gBase = Glean.brokenSiteReport;
    860 
    861    if (reason) {
    862      gBase.breakageCategory.set(reason);
    863    }
    864 
    865    gBase.description.set(description);
    866    gBase.url.set(url);
    867 
    868    const details = await currentTabWebcompatDetailsPromise;
    869 
    870    if (!details) {
    871      GleanPings.brokenSiteReport.submit();
    872      return;
    873    }
    874 
    875    if (!blockedTrackersCheckbox.checked) {
    876      delete details.antitracking.blockedOrigins;
    877    }
    878 
    879    for (const categoryItems of Object.values(details)) {
    880      for (let [name, { glean, json, value }] of Object.entries(
    881        categoryItems
    882      )) {
    883        if (!glean) {
    884          continue;
    885        }
    886        // Transform glean=xx.yy.zz to brokenSiteReportXxYyZz.
    887        glean =
    888          "brokenSiteReport" +
    889          glean
    890            .split(".")
    891            .map(v => `${v[0].toUpperCase()}${v.substr(1)}`)
    892            .join("");
    893        if (json) {
    894          name = `${name}Json`;
    895          value = JSON.stringify(value);
    896        }
    897        Glean[glean][name].set(value);
    898      }
    899    }
    900 
    901    GleanPings.brokenSiteReport.submit();
    902  }
    903 
    904  open(event) {
    905    const { target } = event.sourceEvent;
    906    const { selectedBrowser } = target.ownerGlobal.gBrowser;
    907    const { ownerGlobal } = selectedBrowser;
    908    const { document } = ownerGlobal;
    909 
    910    switch (target.id) {
    911      case "appMenu-report-broken-site-button":
    912        ownerGlobal.PanelUI.showSubView(
    913          ReportBrokenSite.MAIN_PANELVIEW_ID,
    914          target
    915        );
    916        break;
    917      case "protections-popup-report-broken-site-button":
    918        document
    919          .getElementById("protections-popup-multiView")
    920          .showSubView(ReportBrokenSite.MAIN_PANELVIEW_ID);
    921        break;
    922      case "help_reportBrokenSite": {
    923        // hide the hamburger menu first, as we overlap with it.
    924        const appMenuPopup = document.getElementById("appMenu-popup");
    925        appMenuPopup?.hidePopup();
    926 
    927        ownerGlobal.PanelUI.showSubView(
    928          ReportBrokenSite.MAIN_PANELVIEW_ID,
    929          ownerGlobal.PanelUI.menuButton
    930        );
    931        break;
    932      }
    933    }
    934  }
    935 })();