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 })();