diff options
Diffstat (limited to '')
49 files changed, 10641 insertions, 0 deletions
diff --git a/browser/components/firefoxview/colorways-card.mjs b/browser/components/firefoxview/colorways-card.mjs new file mode 100644 index 0000000000..31c95c26ba --- /dev/null +++ b/browser/components/firefoxview/colorways-card.mjs @@ -0,0 +1,179 @@ +/* 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/. */ + +const { BuiltInThemes } = ChromeUtils.importESModule( + "resource:///modules/BuiltInThemes.sys.mjs" +); +const { AddonManager } = ChromeUtils.import( + "resource://gre/modules/AddonManager.jsm" +); +const { NimbusFeatures } = ChromeUtils.import( + "resource://nimbus/ExperimentAPI.jsm" +); + +class ColorwaysCard extends HTMLElement { + constructor() { + super(); + this._selectedColorwayId = null; + this._colorwayCollectionName = ""; + this._initPromise = Promise.all([ + this._getSelectedColorway(), + this._getLocalizedStrings(), + ]); + } + + onEnabled(addon) { + if (addon.type == "theme") { + this._selectedColorwayId = BuiltInThemes.isColorwayFromCurrentCollection( + addon.id + ) + ? addon.id + : null; + this._render(); + } + } + + connectedCallback() { + const colorwaysCollection = + NimbusFeatures.majorRelease2022.getVariable("colorwayCloset") && + BuiltInThemes.findActiveColorwayCollection(); + if (!colorwaysCollection) { + this.hidden = true; + return; + } + this.button = this.querySelector("#colorways-button"); + this.collection_title = this.querySelector("#colorways-collection-title"); + this.description = this.querySelector("#colorways-collection-description"); + this.expiry = this.querySelector("#colorways-collection-expiry-date"); + this.figure = this.querySelector("#colorways-collection-figure"); + this.button.addEventListener("click", () => { + const { ColorwayClosetOpener } = ChromeUtils.import( + "resource:///modules/ColorwayClosetOpener.jsm" + ); + ColorwayClosetOpener.openModal({ + source: "firefoxview", + }); + }); + this._initPromise.then(() => this._render()); + AddonManager.addAddonListener(this); + window.addEventListener("unload", () => this.cleanup()); + } + + cleanup() { + AddonManager.removeAddonListener(this); + } + + disconnectedCallback() { + this.cleanup(); + } + + async _getSelectedColorway() { + await BuiltInThemes.ensureBuiltInThemes(); + this._selectedColorwayId = + (await AddonManager.getAddonsByTypes(["theme"])).find( + theme => + theme.isActive && + BuiltInThemes.isColorwayFromCurrentCollection(theme.id) + )?.id || null; + } + + async _getLocalizedStrings() { + let l10nIds = [ + "colorway-intensity-soft", + "colorway-intensity-balanced", + "colorway-intensity-bold", + ]; + const collection = BuiltInThemes.findActiveColorwayCollection(); + if (collection) { + l10nIds.push(collection.l10nId.title); + } + let l10nValues = await document.l10n.formatValues(l10nIds); + if (collection) { + this._colorwayCollectionName = l10nValues.pop(); + } + this._intensityL10nValue = new Map( + l10nValues.map((string, index) => [l10nIds[index], string]) + ); + } + + _render() { + this._showData(this._getData()); + } + + _getData() { + let collection = BuiltInThemes.findActiveColorwayCollection(); + if (!collection) { + return {}; + } + let colorway = null; + if (this._selectedColorwayId) { + colorway = { + name: BuiltInThemes.getLocalizedColorwayGroupName( + this._selectedColorwayId + ), + figureUrl: BuiltInThemes.builtInThemeMap.get(this._selectedColorwayId) + .figureUrl, + intensity: this._intensityL10nValue.get( + BuiltInThemes.getColorwayIntensityL10nId(this._selectedColorwayId) + ), + }; + } + return { + collection, + colorway, + figureUrl: colorway?.figureUrl || collection.figureUrl, + }; + } + + _showData({ collection, colorway, figureUrl }) { + if (colorway) { + this.expiry.hidden = true; + this.collection_title.removeAttribute("data-l10n-id"); + this.collection_title.textContent = colorway.name; + if (colorway.intensity) { + document.l10n.setAttributes( + this.description, + "firefoxview-colorway-description", + { + intensity: colorway.intensity, + collection: this._colorwayCollectionName, + } + ); + } else { + document.l10n.setAttributes(this.description, collection.l10nId.title); + } + document.l10n.setAttributes( + this.button, + "firefoxview-change-colorway-button" + ); + } else { + this.expiry.hidden = false; + document.l10n.setAttributes( + this.expiry.firstElementChild, + "colorway-collection-expiry-label", + { + expiryDate: collection.expiry.getTime(), + } + ); + if (collection.l10nId.description) { + document.l10n.setAttributes( + this.description, + collection.l10nId.description + ); + } + document.l10n.setAttributes( + this.collection_title, + collection.l10nId.title + ); + document.l10n.setAttributes( + this.button, + "firefoxview-try-colorways-button" + ); + } + this.figure.src = figureUrl || ""; + this.hidden = false; + } +} + +customElements.define("colorways-card", ColorwaysCard); diff --git a/browser/components/firefoxview/content/callout-colorways-dark.svg b/browser/components/firefoxview/content/callout-colorways-dark.svg new file mode 100644 index 0000000000..8bc3683aed --- /dev/null +++ b/browser/components/firefoxview/content/callout-colorways-dark.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg width="350" height="152" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#a)"><path opacity=".12" d="M236.131 117.019c-3.755 3.501-8.225 6.403-13.095 8.502-4.871 2.1-10.025 3.345-15.099 3.65-7.476.392-14.515-1.282-22.034-.389-16.239 1.92-30.609 15.139-46.874 17.747-13.603 2.185-25.256-3.491-35.716-8.983-8.21-4.315-16.49-8.681-22.92-14.657-6.43-5.977-10.96-13.876-10.516-22.767.354-7.326 3.977-14.783 7.793-21.961 9.394-17.652 20.733-35.366 37.515-49.364 16.782-13.998 39.973-23.77 60.416-21.46 9.955 1.122 19.101 4.995 29.589 3.649 11.004-1.411 22.594-8.49 32.599-6.3 3.694.807 6.626 2.834 9.025 5.163 5.74 5.628 8.789 13.053 8.7 21.186-.117 8.268-3.492 17.077-1.7 24.899 2.316 10.124 12.539 18.643 5.968 30.378-5.287 9.432-15.242 22.777-23.651 30.707Z" fill="#97BDFC"/><path d="M245.658 128.93h33.857M177.943 136.35h124.144M57.248 106.234h33.858M75.305 112.013h33.858" stroke="#FBFBFE" stroke-width="1.548" stroke-linecap="round" stroke-linejoin="round"/><g clip-path="url(#b)"><rect x="79.805" y="24.233" width="113.695" height="74.905" rx="6.688" fill="#42414D"/><path stroke="#FBFBFE" stroke-width=".702" d="M79.454 40.024h119.31v8.422H79.454z"/><path fill="#00A84C" fill-opacity=".24" stroke="#FBFBFE" stroke-width=".702" d="M79.392 39.307h119.255V23.232H79.392z"/><rect x="86.214" y="29.195" width="28.931" height="5.926" rx=".697" fill="#009844" stroke="#FBFBFE" stroke-width=".702"/><rect x="120.511" y="29.195" width="28.931" height="5.926" rx=".697" fill="#009844" stroke="#FBFBFE" stroke-width=".702"/><circle cx="87.525" cy="44.586" r="2.456" stroke="#FBFBFE" stroke-width=".702"/><circle cx="95.245" cy="44.586" r="2.456" stroke="#FBFBFE" stroke-width=".702"/><path d="m184.728 86.695-8.071 8.071M191.044 85.291l-8.071 8.071" stroke="#FBFBFE" stroke-width="1.053" stroke-linecap="round" stroke-linejoin="round"/></g><rect x="79.103" y="23.531" width="115.099" height="76.309" rx="7.39" stroke="#FBFBFE" stroke-width="1.404"/><g clip-path="url(#c)"><rect x="100.859" y="36.164" width="113.695" height="74.905" rx="6.688" fill="#42414D"/><path stroke="#FBFBFE" stroke-width=".702" d="M100.508 51.955h119.31v8.422h-119.31z"/><path fill="#FBCB28" fill-opacity=".4" d="M100.012 51.402h119.956V34.625H100.012z"/><rect x="107.268" y="41.126" width="28.931" height="5.926" rx=".697" fill="#F8D044" stroke="#FBFBFE" stroke-width=".702"/><rect x="141.564" y="41.126" width="28.931" height="5.926" rx=".697" fill="#F8D044" stroke="#FBFBFE" stroke-width=".702"/><circle cx="108.579" cy="56.517" r="2.456" stroke="#FBFBFE" stroke-width=".702"/><circle cx="116.299" cy="56.517" r="2.456" stroke="#FBFBFE" stroke-width=".702"/><path d="m205.781 98.626-8.071 8.071M212.098 97.223l-8.071 8.071" stroke="#FBFBFE" stroke-width="1.053" stroke-linecap="round" stroke-linejoin="round"/></g><rect x="100.157" y="35.462" width="115.099" height="76.309" rx="7.39" stroke="#FBFBFE" stroke-width="1.404"/><g clip-path="url(#d)"><rect x="135.951" y="53.709" width="113.695" height="74.905" rx="6.688" fill="#42414D"/><rect x="142.36" y="58.671" width="28.931" height="5.926" rx=".697" fill="#8A52FF" stroke="#FBFBFE" stroke-width=".702"/><rect x="176.656" y="58.671" width="28.931" height="5.926" rx=".697" fill="#8A52FF" stroke="#FBFBFE" stroke-width=".702"/><path stroke="#FBFBFE" stroke-width=".702" d="M135.6 69.5h119.31v8.422H135.6z"/><path d="M135.244 69.017h119.957v-6.71c0-5.56-4.507-10.067-10.067-10.067H145.31c-5.559 0-10.066 4.507-10.066 10.067v6.71Z" fill="#5F17F3" fill-opacity=".19"/><circle cx="143.671" cy="74.062" r="2.456" stroke="#FBFBFE" stroke-width=".702"/><circle cx="151.391" cy="74.062" r="2.456" stroke="#FBFBFE" stroke-width=".702"/><path d="m240.873 116.172-8.071 8.071M247.189 114.768l-8.071 8.071" stroke="#FBFBFE" stroke-width="1.053" stroke-linecap="round" stroke-linejoin="round"/></g><rect x="135.249" y="53.008" width="115.099" height="76.309" rx="7.39" stroke="#FBFBFE" stroke-width="1.404"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h350v152H0z"/></clipPath><clipPath id="b"><rect x="79.805" y="24.233" width="113.695" height="74.905" rx="6.688" fill="#fff"/></clipPath><clipPath id="c"><rect x="100.859" y="36.164" width="113.695" height="74.905" rx="6.688" fill="#fff"/></clipPath><clipPath id="d"><rect x="135.951" y="53.709" width="113.695" height="74.905" rx="6.688" fill="#fff"/></clipPath></defs></svg> diff --git a/browser/components/firefoxview/content/callout-colorways.svg b/browser/components/firefoxview/content/callout-colorways.svg new file mode 100644 index 0000000000..4096e14278 --- /dev/null +++ b/browser/components/firefoxview/content/callout-colorways.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 246.38 142.74"><defs><style>.cls-1{fill:#a7ffce;}.cls-2{fill:#fff;}.cls-3{fill:#ecc74a;}.cls-4{fill:#ffe072;}.cls-5{fill:#9768f9;}.cls-6{clip-path:url(#clippath-2);}.cls-7{opacity:.4;}.cls-8{clip-path:url(#clippath-1);}.cls-9{opacity:.12;}.cls-10{opacity:.19;}.cls-11{clip-path:url(#clippath);}.cls-12{fill:#8a52ff;}.cls-13{fill:none;}.cls-14{fill:#d2fae4;}.cls-15{fill:#97bdfc;}.cls-16{fill:#231f20;}</style><clipPath id="clippath"><rect class="cls-13" x="23.33" y="19.96" width="113.69" height="74.91" rx="6.69" ry="6.69"/></clipPath><clipPath id="clippath-1"><rect class="cls-13" x="44.38" y="31.9" width="113.69" height="74.91" rx="6.69" ry="6.69"/></clipPath><clipPath id="clippath-2"><rect class="cls-13" x="79.48" y="49.44" width="113.69" height="74.91" rx="6.69" ry="6.69"/></clipPath></defs><g class="cls-9"><path class="cls-15" d="M179.66,112.75c-3.75,3.5-8.22,6.4-13.1,8.5-4.87,2.1-10.02,3.34-15.1,3.65-7.48,.39-14.51-1.28-22.03-.39-16.24,1.92-30.61,15.14-46.87,17.75-13.6,2.18-25.26-3.49-35.72-8.98-8.21-4.32-16.49-8.68-22.92-14.66s-10.96-13.88-10.52-22.77c.35-7.33,3.98-14.78,7.79-21.96,9.39-17.65,20.73-35.37,37.52-49.36C75.49,10.53,98.68,.76,119.13,3.07c9.96,1.12,19.1,5,29.59,3.65,11-1.41,22.59-8.49,32.6-6.3,3.69,.81,6.63,2.83,9.02,5.16,5.74,5.63,8.79,13.05,8.7,21.19-.12,8.27-3.49,17.08-1.7,24.9,2.32,10.12,12.54,18.64,5.97,30.38-5.29,9.43-15.24,22.78-23.65,30.71h0Z"/></g><path class="cls-16" d="M223.04,125.43h-33.86c-.43,0-.77-.35-.77-.77s.35-.77,.77-.77h33.86c.43,0,.77,.35,.77,.77s-.35,.77-.77,.77Z"/><path class="cls-16" d="M245.61,132.85H121.47c-.43,0-.77-.35-.77-.77s.35-.77,.77-.77h124.14c.43,0,.77,.35,.77,.77s-.35,.77-.77,.77Z"/><path class="cls-16" d="M34.63,102.74H.77c-.43,0-.77-.35-.77-.77s.35-.77,.77-.77H34.63c.43,0,.77,.35,.77,.77s-.35,.77-.77,.77Z"/><path class="cls-16" d="M52.69,108.52H18.83c-.43,0-.77-.35-.77-.77s.35-.77,.77-.77H52.69c.43,0,.77,.35,.77,.77s-.35,.77-.77,.77Z"/><g class="cls-11"><g><rect class="cls-2" x="23.33" y="19.96" width="113.69" height="74.91" rx="6.69" ry="6.69"/><path class="cls-16" d="M142.64,44.53H22.63v-9.12h120.01v9.12Zm-119.31-.7h118.61v-7.72H23.33v7.72Z"/><g><rect class="cls-14" x="22.92" y="18.96" width="119.25" height="16.08"/><path class="cls-16" d="M142.52,35.39H22.57V18.61h119.96v16.78h0Zm-119.25-.7h118.55v-15.37H23.27v15.37Z"/></g><g><rect class="cls-1" x="29.74" y="24.93" width="28.93" height="5.93" rx=".7" ry=".7"/><path class="cls-16" d="M57.97,31.2H30.44c-.58,0-1.05-.47-1.05-1.05v-4.53c0-.58,.47-1.05,1.05-1.05h27.54c.58,0,1.05,.47,1.05,1.05v4.53c0,.58-.47,1.05-1.05,1.05h-.01Zm-27.54-5.93c-.19,0-.35,.16-.35,.35v4.53c0,.19,.16,.35,.35,.35h27.54c.19,0,.35-.16,.35-.35v-4.53c0-.19-.15-.35-.35-.35H30.43Z"/></g><g><rect class="cls-1" x="64.04" y="24.93" width="28.93" height="5.93" rx=".7" ry=".7"/><path class="cls-16" d="M92.27,31.2h-27.54c-.58,0-1.05-.47-1.05-1.05v-4.53c0-.58,.47-1.05,1.05-1.05h27.54c.58,0,1.05,.47,1.05,1.05v4.53c0,.58-.47,1.05-1.05,1.05Zm-27.54-5.93c-.19,0-.35,.16-.35,.35v4.53c0,.19,.15,.35,.35,.35h27.54c.19,0,.35-.16,.35-.35v-4.53c0-.19-.16-.35-.35-.35h-27.54Z"/></g><path class="cls-16" d="M31.05,43.12c-1.55,0-2.81-1.26-2.81-2.81s1.26-2.81,2.81-2.81,2.81,1.26,2.81,2.81-1.26,2.81-2.81,2.81Zm0-4.91c-1.16,0-2.1,.94-2.1,2.11s.94,2.1,2.1,2.1,2.11-.94,2.11-2.1-.94-2.11-2.11-2.11Z"/><path class="cls-16" d="M38.77,43.12c-1.55,0-2.81-1.26-2.81-2.81s1.26-2.81,2.81-2.81,2.81,1.26,2.81,2.81-1.26,2.81-2.81,2.81Zm0-4.91c-1.16,0-2.1,.94-2.1,2.11s.94,2.1,2.1,2.1,2.11-.94,2.11-2.1-.94-2.11-2.11-2.11Z"/></g></g><path class="cls-16" d="M130.34,96.27H30.02c-4.46,0-8.09-3.63-8.09-8.09V26.65c0-4.46,3.63-8.09,8.09-8.09h100.32c4.46,0,8.09,3.63,8.09,8.09v61.53c0,4.46-3.63,8.09-8.09,8.09ZM30.02,19.96c-3.69,0-6.69,3-6.69,6.69v61.53c0,3.69,3,6.69,6.69,6.69h100.32c3.69,0,6.69-3,6.69-6.69V26.65c0-3.69-3-6.69-6.69-6.69H30.02Z"/><g class="cls-8"><g><rect class="cls-2" x="44.38" y="31.9" width="113.69" height="74.91" rx="6.69" ry="6.69"/><path class="cls-16" d="M163.69,56.46H43.68v-9.12h120.01v9.12Zm-119.31-.7h118.61v-7.72H44.38v7.72Z"/><g class="cls-7"><rect class="cls-3" x="43.54" y="30.36" width="119.96" height="16.78"/></g><g><rect class="cls-4" x="50.79" y="36.86" width="28.93" height="5.93" rx=".7" ry=".7"/><path class="cls-16" d="M79.03,43.13h-27.54c-.58,0-1.05-.47-1.05-1.05v-4.53c0-.58,.47-1.05,1.05-1.05h27.54c.58,0,1.05,.47,1.05,1.05v4.53c0,.58-.47,1.05-1.05,1.05Zm-27.54-5.93c-.19,0-.35,.16-.35,.35v4.53c0,.19,.16,.35,.35,.35h27.54c.19,0,.35-.16,.35-.35v-4.53c0-.19-.16-.35-.35-.35h-27.54Z"/></g><g><rect class="cls-4" x="85.09" y="36.86" width="28.93" height="5.93" rx=".7" ry=".7"/><path class="cls-16" d="M113.32,43.13h-27.54c-.58,0-1.05-.47-1.05-1.05v-4.53c0-.58,.47-1.05,1.05-1.05h27.54c.58,0,1.05,.47,1.05,1.05v4.53c0,.58-.47,1.05-1.05,1.05Zm-27.54-5.93c-.19,0-.35,.16-.35,.35v4.53c0,.19,.16,.35,.35,.35h27.54c.19,0,.35-.16,.35-.35v-4.53c0-.19-.15-.35-.35-.35h-27.54Z"/></g><path class="cls-16" d="M52.1,55.06c-1.55,0-2.81-1.26-2.81-2.81s1.26-2.81,2.81-2.81,2.81,1.26,2.81,2.81-1.26,2.81-2.81,2.81Zm0-4.91c-1.16,0-2.11,.94-2.11,2.11s.94,2.1,2.11,2.1,2.1-.94,2.1-2.1-.94-2.11-2.1-2.11Z"/><path class="cls-16" d="M59.82,55.06c-1.55,0-2.81-1.26-2.81-2.81s1.26-2.81,2.81-2.81,2.81,1.26,2.81,2.81-1.26,2.81-2.81,2.81Zm0-4.91c-1.16,0-2.1,.94-2.1,2.11s.94,2.1,2.1,2.1,2.11-.94,2.11-2.1-.94-2.11-2.11-2.11Z"/></g></g><path class="cls-16" d="M151.39,108.2H51.07c-4.46,0-8.09-3.63-8.09-8.09V38.58c0-4.46,3.63-8.09,8.09-8.09h100.32c4.46,0,8.09,3.63,8.09,8.09v61.53c0,4.46-3.63,8.09-8.09,8.09ZM51.07,31.89c-3.69,0-6.69,3-6.69,6.69v61.53c0,3.69,3,6.69,6.69,6.69h100.32c3.69,0,6.69-3,6.69-6.69V38.58c0-3.69-3-6.69-6.69-6.69H51.07Z"/><g class="cls-6"><g><rect class="cls-2" x="79.48" y="49.44" width="113.69" height="74.91" rx="6.69" ry="6.69"/><g><rect class="cls-12" x="85.89" y="54.4" width="28.93" height="5.93" rx=".7" ry=".7"/><path class="cls-16" d="M114.12,60.68h-27.54c-.58,0-1.05-.47-1.05-1.05v-4.53c0-.58,.47-1.05,1.05-1.05h27.54c.58,0,1.05,.47,1.05,1.05v4.53c0,.58-.47,1.05-1.05,1.05Zm-27.54-5.93c-.19,0-.35,.16-.35,.35v4.53c0,.19,.16,.35,.35,.35h27.54c.19,0,.35-.16,.35-.35v-4.53c0-.19-.16-.35-.35-.35h-27.54Z"/></g><g><rect class="cls-12" x="120.18" y="54.4" width="28.93" height="5.93" rx=".7" ry=".7"/><path class="cls-16" d="M148.42,60.68h-27.54c-.58,0-1.05-.47-1.05-1.05v-4.53c0-.58,.47-1.05,1.05-1.05h27.54c.58,0,1.05,.47,1.05,1.05v4.53c0,.58-.47,1.05-1.05,1.05Zm-27.54-5.93c-.19,0-.35,.16-.35,.35v4.53c0,.19,.16,.35,.35,.35h27.54c.19,0,.35-.16,.35-.35v-4.53c0-.19-.16-.35-.35-.35h-27.54Z"/></g><path class="cls-16" d="M198.79,74H78.77v-9.12h120.01v9.12h0Zm-119.31-.7h118.61v-7.72H79.48v7.72Z"/><g class="cls-10"><path class="cls-5" d="M78.77,64.75h119.96v-6.71c0-5.56-4.51-10.07-10.07-10.07H88.84c-5.56,0-10.07,4.51-10.07,10.07v6.71Z"/></g><path class="cls-16" d="M87.2,72.6c-1.55,0-2.81-1.26-2.81-2.81s1.26-2.81,2.81-2.81,2.81,1.26,2.81,2.81-1.26,2.81-2.81,2.81Zm0-4.91c-1.16,0-2.1,.94-2.1,2.1s.94,2.11,2.1,2.11,2.11-.94,2.11-2.11-.94-2.1-2.11-2.1Z"/><path class="cls-16" d="M94.92,72.6c-1.55,0-2.81-1.26-2.81-2.81s1.26-2.81,2.81-2.81,2.81,1.26,2.81,2.81-1.26,2.81-2.81,2.81Zm0-4.91c-1.16,0-2.11,.94-2.11,2.1s.94,2.11,2.11,2.11,2.1-.94,2.1-2.11-.94-2.1-2.1-2.1Z"/><path class="cls-16" d="M176.33,120.5c-.13,0-.27-.05-.37-.15-.21-.21-.21-.54,0-.74l8.07-8.07c.21-.21,.54-.21,.74,0,.21,.21,.21,.54,0,.74l-8.07,8.07c-.1,.1-.24,.15-.37,.15h0Z"/><path class="cls-16" d="M182.64,119.1c-.13,0-.27-.05-.37-.15-.21-.21-.21-.54,0-.74l8.07-8.07c.21-.21,.54-.21,.74,0,.21,.21,.21,.54,0,.74l-8.07,8.07c-.1,.1-.24,.15-.37,.15h0Z"/></g></g><path class="cls-16" d="M186.48,125.75H86.16c-4.46,0-8.09-3.63-8.09-8.09V56.13c0-4.46,3.63-8.09,8.09-8.09h100.32c4.46,0,8.09,3.63,8.09,8.09v61.53c0,4.46-3.63,8.09-8.09,8.09h0ZM86.16,49.44c-3.69,0-6.69,3-6.69,6.69v61.53c0,3.69,3,6.69,6.69,6.69h100.32c3.69,0,6.69-3,6.69-6.69V56.13c0-3.69-3-6.69-6.69-6.69H86.16Z"/></svg> diff --git a/browser/components/firefoxview/content/callout-tab-pickup-dark.svg b/browser/components/firefoxview/content/callout-tab-pickup-dark.svg new file mode 100644 index 0000000000..b38684c38a --- /dev/null +++ b/browser/components/firefoxview/content/callout-tab-pickup-dark.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg width="350" height="152" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#a)"><path opacity=".07" d="M165.721 125.287c-30.865-16.041-92.958 7.521-101.635-16.041-6.348-17.372 26.486-23.769 9.488-60.003s55.914-54.573 80.964-38.917c25.05 15.656 77.755-4.574 109.145 13.313 14.543 8.334 25.627 24.337 17.613 35.431-3.318 4.586-9.475 7.678-11.451 12.827-3.85 10.047 11.864 29.166 11.451 39.971-1.946 50.785-84.71 29.461-115.575 13.419Z" fill="#FF6D33"/><path d="M60.315 95.195H163.23M46 90.723h146.666" stroke="#FBFBFE" stroke-width="1.123" stroke-linecap="round" stroke-linejoin="round"/><path d="M84.792 100.023c-1.945 2.248-.348 5.742 2.625 5.742h118.985c2.737 0 4.397-3.02 2.93-5.331l-4.554-7.176a3.47 3.47 0 0 0-2.93-1.611H93.628a3.47 3.47 0 0 0-2.625 1.2l-6.21 7.176Z" fill="#42414D" stroke="#FBFBFE" stroke-width="1.431"/><rect x="-.716" y=".716" width="39.237" height="63.159" rx="9.451" transform="matrix(-1 0 0 1 257.44 60.43)" fill="#42414D" stroke="#FBFBFE" stroke-width="1.431"/><rect x="91.533" y="16.29" width="113.994" height="78.111" rx="9.451" fill="#42414D" stroke="#FBFBFE" stroke-width="1.431"/><g clip-path="url(#b)"><rect x="97.395" y="21.559" width="101.67" height="66.983" rx="5.981" fill="#42414D"/><rect x="102.768" y="24.744" width="26.587" height="6.015" rx=".981" fill="#E0490E" stroke="#FBFBFE" stroke-width=".716"/><path stroke="#FBFBFE" stroke-width=".895" d="M93.863 34.811h106.461v8.946H93.863z"/><circle cx="104.711" cy="38.749" r="1.942" fill="#42414D" stroke="#FBFBFE" stroke-width=".895"/><circle cx="111.217" cy="38.749" r="1.942" fill="#42414D" stroke="#FBFBFE" stroke-width=".895"/><rect x="135.672" y="24.744" width="26.587" height="6.015" rx=".981" fill="#E0490E" stroke="#FBFBFE" stroke-width=".716"/></g><rect x="96.859" y="21.022" width="102.744" height="68.056" rx="6.517" stroke="#FBFBFE" stroke-width="1.074"/><g clip-path="url(#c)"><rect x="224.184" y="66.406" width="29.305" height="52.629" rx="5.981" fill="#fff" fill-opacity=".05"/><rect x="228.505" y="70.144" width="21.471" height="5.368" rx=".895" fill="#E0490E" stroke="#FBFBFE" stroke-width=".895"/><path stroke="#FBFBFE" stroke-width=".895" d="M220.006 79.541h101.093v4.473H220.006z"/></g><rect x="223.513" y="65.735" width="30.647" height="53.971" rx="6.652" stroke="#FBFBFE" stroke-width="1.342"/><circle cx="238.538" cy="113.658" r="3.438" fill="#42414D" stroke="#FBFBFE" stroke-width=".895"/><path fill-rule="evenodd" clip-rule="evenodd" d="m252.813 42.754-6.381.1c-.181-11.601-9.901-21.023-21.212-20.846-5.221.081-10.124 1.898-13.844 5.148-.571.589-.852 1.173-.843 1.753.009.58.013.87.312 1.446.884.856 2.339 1.124 3.195.24 3.15-2.66 6.897-4.169 10.958-4.232 9.28-.145 16.939 7.278 17.084 16.558l-6.381.1c-1.16.018-1.717 1.477-.834 2.334l8.252 8.284 2.61-.041 8.284-8.252c.852-1.174-.04-2.61-1.2-2.592Zm-75.031 81.36 6.381-.1c1.16-.018 1.718-1.477.834-2.333l-8.252-8.284-2.61.041-7.994 8.247c-.852 1.174.041 2.61 1.201 2.592l6.38-.1c.181 11.601 9.901 21.023 21.502 20.842 4.931-.077 9.834-1.894 13.554-5.143.857-.884 1.124-2.339.241-3.195-.884-.857-2.339-1.124-3.195-.24-3.15 2.66-6.897 4.169-10.958 4.232-9.28.145-16.939-7.278-17.084-16.559Z" fill="#E0490E"/><path d="M243.713 124.18h27.734M245.503 127.402h18.787" stroke="#FBFBFE" stroke-width="1.123" stroke-linecap="round" stroke-linejoin="round"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h350v152H0z"/></clipPath><clipPath id="b"><rect x="97.395" y="21.559" width="101.67" height="66.983" rx="5.981" fill="#fff"/></clipPath><clipPath id="c"><rect x="224.184" y="66.406" width="29.305" height="52.629" rx="5.981" fill="#fff"/></clipPath></defs></svg> diff --git a/browser/components/firefoxview/content/callout-tab-pickup.svg b/browser/components/firefoxview/content/callout-tab-pickup.svg new file mode 100644 index 0000000000..1ccc36dcc8 --- /dev/null +++ b/browser/components/firefoxview/content/callout-tab-pickup.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 276.11 140.32"><defs><style>.cls-1{fill:#fff;}.cls-2{clip-path:url(#clippath-1);}.cls-3{opacity:.05;}.cls-4{isolation:isolate;opacity:.07;}.cls-5{fill:#fe7e4b;}.cls-6,.cls-7{fill:#ff7139;}.cls-8{clip-path:url(#clippath);}.cls-9{fill:none;}.cls-10{opacity:.1;}.cls-7{fill-rule:evenodd;}</style><clipPath id="clippath"><rect class="cls-9" x="51.96" y="16.85" width="101.67" height="66.98" rx="5.98" ry="5.98"/></clipPath><clipPath id="clippath-1"><rect class="cls-9" x="178.75" y="61.7" width="29.3" height="52.63" rx="5.98" ry="5.98"/></clipPath></defs><g class="cls-4"><path class="cls-6" d="M120.28,120.58c-30.86-16.04-92.96,7.52-101.63-16.04-6.35-17.37,26.49-23.77,9.49-60C11.14,8.31,84.05-10.03,109.1,5.62c25.05,15.66,77.76-4.57,109.15,13.31,14.54,8.33,25.63,24.34,17.61,35.43-3.32,4.59-9.47,7.68-11.45,12.83-3.85,10.05,11.86,29.17,11.45,39.97-1.95,50.79-84.71,29.46-115.57,13.42Z"/></g><path d="M117.79,91.05H14.88c-.31,0-.56-.25-.56-.56s.25-.56,.56-.56H117.79c.31,0,.56,.25,.56,.56s-.25,.56-.56,.56Z"/><path d="M147.23,86.58H.56c-.31,0-.56-.25-.56-.56s.25-.56,.56-.56H147.23c.31,0,.56,.25,.56,.56s-.25,.56-.56,.56Z"/><g><path class="cls-1" d="M39.35,95.32c-1.95,2.25-.35,5.74,2.62,5.74h118.99c2.74,0,4.4-3.02,2.93-5.33l-4.55-7.18c-.64-1-1.74-1.61-2.93-1.61H48.19c-1.01,0-1.97,.44-2.62,1.2l-6.21,7.18Z"/><path d="M160.96,101.78H41.98c-1.66,0-3.12-.94-3.81-2.45s-.44-3.22,.64-4.48l6.21-7.18c.8-.92,1.95-1.45,3.17-1.45h108.22c1.44,0,2.76,.73,3.53,1.94l4.55,7.18c.83,1.31,.88,2.9,.13,4.26-.75,1.36-2.12,2.17-3.67,2.17Zm-112.77-14.12c-.8,0-1.56,.35-2.08,.95l-6.21,7.18c-.71,.83-.88,1.95-.42,2.95,.45,.99,1.41,1.61,2.51,1.61h118.99c1.02,0,1.92-.53,2.41-1.43,.49-.89,.46-1.94-.09-2.8l-4.55-7.18c-.51-.8-1.38-1.28-2.33-1.28H48.19Z"/></g><g><rect class="cls-1" x="173.48" y="56.44" width="39.24" height="63.16" rx="9.45" ry="9.45"/><path d="M203.27,120.32h-20.33c-5.61,0-10.17-4.56-10.17-10.17v-44.26c0-5.61,4.56-10.17,10.17-10.17h20.33c5.61,0,10.17,4.56,10.17,10.17v44.26c0,5.61-4.56,10.17-10.17,10.17Zm-20.33-63.16c-4.82,0-8.74,3.92-8.74,8.74v44.26c0,4.82,3.92,8.74,8.74,8.74h20.33c4.82,0,8.74-3.92,8.74-8.74v-44.26c0-4.82-3.92-8.74-8.74-8.74h-20.33Z"/></g><g><rect class="cls-1" x="46.09" y="11.59" width="113.99" height="78.11" rx="9.45" ry="9.45"/><path d="M150.64,90.41H55.55c-5.61,0-10.17-4.56-10.17-10.17V21.04c0-5.61,4.56-10.17,10.17-10.17h95.09c5.61,0,10.17,4.56,10.17,10.17v59.21c0,5.61-4.56,10.17-10.17,10.17ZM55.55,12.3c-4.82,0-8.74,3.92-8.74,8.74v59.21c0,4.82,3.92,8.74,8.74,8.74h95.09c4.82,0,8.74-3.92,8.74-8.74V21.04c0-4.82-3.92-8.74-8.74-8.74H55.55Z"/></g><g class="cls-8"><g><g class="cls-10"><rect class="cls-1" x="51.96" y="16.85" width="101.67" height="66.98" rx="5.98" ry="5.98"/></g><g><rect class="cls-6" x="57.33" y="20.04" width="26.59" height="6.01" rx=".98" ry=".98"/><path d="M82.94,26.41h-24.62c-.74,0-1.34-.6-1.34-1.34v-4.05c0-.74,.6-1.34,1.34-1.34h24.62c.74,0,1.34,.6,1.34,1.34v4.05c0,.74-.6,1.34-1.34,1.34Zm-24.62-6.01c-.34,0-.62,.28-.62,.62v4.05c0,.34,.28,.62,.62,.62h24.62c.34,0,.62-.28,.62-.62v-4.05c0-.34-.28-.62-.62-.62h-24.62Z"/></g><path d="M155.33,39.5H47.98v-9.84h107.36v9.84Zm-106.46-.89h105.57v-8.05H48.87v8.05Z"/><g><circle class="cls-1" cx="59.27" cy="34.04" r="1.94"/><path d="M59.27,36.43c-1.32,0-2.39-1.07-2.39-2.39s1.07-2.39,2.39-2.39,2.39,1.07,2.39,2.39-1.07,2.39-2.39,2.39Zm0-3.89c-.82,0-1.5,.67-1.5,1.5s.67,1.5,1.5,1.5,1.5-.67,1.5-1.5-.67-1.5-1.5-1.5Z"/></g><g><circle class="cls-1" cx="65.78" cy="34.04" r="1.94"/><path d="M65.78,36.43c-1.32,0-2.39-1.07-2.39-2.39s1.07-2.39,2.39-2.39,2.39,1.07,2.39,2.39-1.07,2.39-2.39,2.39Zm0-3.89c-.82,0-1.5,.67-1.5,1.5s.67,1.5,1.5,1.5,1.5-.67,1.5-1.5-.67-1.5-1.5-1.5Z"/></g><g><rect class="cls-5" x="90.23" y="20.04" width="26.59" height="6.01" rx=".98" ry=".98"/><path d="M115.84,26.41h-24.62c-.74,0-1.34-.6-1.34-1.34v-4.05c0-.74,.6-1.34,1.34-1.34h24.62c.74,0,1.34,.6,1.34,1.34v4.05c0,.74-.6,1.34-1.34,1.34Zm-24.62-6.01c-.34,0-.62,.28-.62,.62v4.05c0,.34,.28,.62,.62,.62h24.62c.34,0,.62-.28,.62-.62v-4.05c0-.34-.28-.62-.62-.62h-24.62Z"/></g></g></g><path d="M147.65,84.91H57.94c-3.89,0-7.05-3.17-7.05-7.05V22.83c0-3.89,3.16-7.05,7.05-7.05h89.71c3.89,0,7.05,3.16,7.05,7.05v55.02c0,3.89-3.17,7.05-7.05,7.05ZM57.94,16.85c-3.3,0-5.98,2.68-5.98,5.98v55.02c0,3.3,2.68,5.98,5.98,5.98h89.71c3.3,0,5.98-2.68,5.98-5.98V22.83c0-3.3-2.68-5.98-5.98-5.98H57.94Z"/><g class="cls-2"><g><g class="cls-3"><rect class="cls-1" x="178.75" y="61.7" width="29.3" height="52.63" rx="5.98" ry="5.98"/></g><g><rect class="cls-6" x="183.07" y="65.44" width="21.47" height="5.37" rx=".89" ry=".89"/><path d="M203.64,71.26h-19.68c-.74,0-1.34-.6-1.34-1.34v-3.58c0-.74,.6-1.34,1.34-1.34h19.68c.74,0,1.34,.6,1.34,1.34v3.58c0,.74-.6,1.34-1.34,1.34Zm-19.68-5.37c-.25,0-.45,.2-.45,.45v3.58c0,.25,.2,.45,.45,.45h19.68c.25,0,.45-.2,.45-.45v-3.58c0-.25-.2-.45-.45-.45h-19.68Z"/></g><path d="M276.11,79.76h-101.99v-5.37h101.99v5.37Zm-101.09-.89h100.2v-3.58h-100.2v3.58Z"/></g></g><path d="M202.07,115.68h-17.34c-4.04,0-7.32-3.29-7.32-7.32v-40.67c0-4.04,3.29-7.32,7.32-7.32h17.34c4.04,0,7.32,3.28,7.32,7.32v40.67c0,4.04-3.29,7.32-7.32,7.32Zm-17.34-53.97c-3.3,0-5.98,2.68-5.98,5.98v40.67c0,3.3,2.68,5.98,5.98,5.98h17.34c3.3,0,5.98-2.68,5.98-5.98v-40.67c0-3.3-2.68-5.98-5.98-5.98h-17.34Z"/><g><circle class="cls-1" cx="193.1" cy="108.95" r="3.44"/><path d="M193.1,112.84c-2.14,0-3.88-1.74-3.88-3.88s1.74-3.88,3.88-3.88,3.88,1.74,3.88,3.88-1.74,3.88-3.88,3.88Zm0-6.88c-1.65,0-2.99,1.34-2.99,2.99s1.34,2.99,2.99,2.99,2.99-1.34,2.99-2.99-1.34-2.99-2.99-2.99Z"/></g><path class="cls-7" d="M207.37,38.05l-6.38,.1c-.18-11.6-9.9-21.02-21.21-20.85-5.22,.08-10.12,1.9-13.84,5.15-.57,.59-.85,1.17-.84,1.75,0,.58,.01,.87,.31,1.45,.88,.86,2.34,1.12,3.19,.24,3.15-2.66,6.9-4.17,10.96-4.23,9.28-.14,16.94,7.28,17.08,16.56l-6.38,.1c-1.16,.02-1.72,1.48-.83,2.33l8.25,8.28,2.61-.04,8.28-8.25c.85-1.17-.04-2.61-1.2-2.59Zm-75.03,81.36l6.38-.1c1.16-.02,1.72-1.48,.83-2.33l-8.25-8.28-2.61,.04-7.99,8.25c-.85,1.17,.04,2.61,1.2,2.59l6.38-.1c.18,11.6,9.9,21.02,21.5,20.84,4.93-.08,9.83-1.89,13.55-5.14,.86-.88,1.12-2.34,.24-3.2-.88-.86-2.34-1.12-3.19-.24-3.15,2.66-6.9,4.17-10.96,4.23-9.28,.15-16.94-7.28-17.08-16.56Z"/><path d="M226.01,120.04h-27.73c-.31,0-.56-.25-.56-.56s.25-.56,.56-.56h27.73c.31,0,.56,.25,.56,.56s-.25,.56-.56,.56Z"/><path d="M218.85,123.26h-18.79c-.31,0-.56-.25-.56-.56s.25-.56,.56-.56h18.79c.31,0,.56,.25,.56,.56s-.25,.56-.56,.56Z"/></svg> diff --git a/browser/components/firefoxview/content/cfr-lightning-dark.svg b/browser/components/firefoxview/content/cfr-lightning-dark.svg new file mode 100644 index 0000000000..d2a0369f33 --- /dev/null +++ b/browser/components/firefoxview/content/cfr-lightning-dark.svg @@ -0,0 +1,6 @@ +<!-- 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/. --> +<svg width="12" height="16" viewBox="0 0 12 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M4.44111 16H2.83211L3.83111 9.25H1.62411C1.36553 9.24948 1.11079 9.18746 0.88092 9.06905C0.651051 8.95063 0.452651 8.77923 0.302106 8.569C0.151724 8.35787 0.0538634 8.11392 0.0166453 7.85739C-0.0205727 7.60087 0.00392533 7.33916 0.0881063 7.094L2.10311 1.262C2.23102 0.893595 2.47046 0.574149 2.78817 0.348003C3.10589 0.121857 3.48613 0.000228542 3.87611 2.59699e-07H6.91311C7.17198 -0.000146126 7.42713 0.0615948 7.6573 0.180076C7.88747 0.298556 8.08597 0.470343 8.23627 0.681115C8.38657 0.891886 8.48431 1.13553 8.52133 1.39174C8.55835 1.64795 8.53359 1.9093 8.44911 2.154L7.47211 5H10.3741C11.0161 5 11.5821 5.363 11.8501 5.947C11.9819 6.22956 12.0288 6.5443 11.9851 6.853C11.9414 7.1617 11.8091 7.45108 11.6041 7.686L4.44111 16ZM10.5201 6.25H6.42211C6.34254 6.25 6.26412 6.231 6.19336 6.19459C6.12261 6.15819 6.06157 6.10542 6.01531 6.04068C5.96905 5.97593 5.93891 5.90109 5.9274 5.82235C5.91588 5.74362 5.92333 5.66328 5.94911 5.588L7.26811 1.748L6.91411 1.25H3.87511L3.28511 1.671L1.26911 7.502L1.62411 8H4.70011C4.77197 7.99995 4.843 8.01539 4.90835 8.04527C4.97371 8.07514 5.03185 8.11876 5.07883 8.17314C5.12581 8.22752 5.16051 8.29139 5.18058 8.36039C5.20064 8.42939 5.2056 8.50191 5.19511 8.573L4.36511 14.174L10.8031 6.7L10.5201 6.25Z" fill="#fff"/> +</svg> diff --git a/browser/components/firefoxview/content/cfr-lightning.svg b/browser/components/firefoxview/content/cfr-lightning.svg new file mode 100644 index 0000000000..2a6cdc5898 --- /dev/null +++ b/browser/components/firefoxview/content/cfr-lightning.svg @@ -0,0 +1,6 @@ +<!-- 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/. --> +<svg width="12" height="16" viewBox="0 0 12 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M4.44111 16H2.83211L3.83111 9.25H1.62411C1.36553 9.24948 1.11079 9.18746 0.88092 9.06905C0.651051 8.95063 0.452651 8.77923 0.302106 8.569C0.151724 8.35787 0.0538634 8.11392 0.0166453 7.85739C-0.0205727 7.60087 0.00392533 7.33916 0.0881063 7.094L2.10311 1.262C2.23102 0.893595 2.47046 0.574149 2.78817 0.348003C3.10589 0.121857 3.48613 0.000228542 3.87611 2.59699e-07H6.91311C7.17198 -0.000146126 7.42713 0.0615948 7.6573 0.180076C7.88747 0.298556 8.08597 0.470343 8.23627 0.681115C8.38657 0.891886 8.48431 1.13553 8.52133 1.39174C8.55835 1.64795 8.53359 1.9093 8.44911 2.154L7.47211 5H10.3741C11.0161 5 11.5821 5.363 11.8501 5.947C11.9819 6.22956 12.0288 6.5443 11.9851 6.853C11.9414 7.1617 11.8091 7.45108 11.6041 7.686L4.44111 16ZM10.5201 6.25H6.42211C6.34254 6.25 6.26412 6.231 6.19336 6.19459C6.12261 6.15819 6.06157 6.10542 6.01531 6.04068C5.96905 5.97593 5.93891 5.90109 5.9274 5.82235C5.91588 5.74362 5.92333 5.66328 5.94911 5.588L7.26811 1.748L6.91411 1.25H3.87511L3.28511 1.671L1.26911 7.502L1.62411 8H4.70011C4.77197 7.99995 4.843 8.01539 4.90835 8.04527C4.97371 8.07514 5.03185 8.11876 5.07883 8.17314C5.12581 8.22752 5.16051 8.29139 5.18058 8.36039C5.20064 8.42939 5.2056 8.50191 5.19511 8.573L4.36511 14.174L10.8031 6.7L10.5201 6.25Z" fill="#5B5B66"/> +</svg> diff --git a/browser/components/firefoxview/content/recently-closed-empty.svg b/browser/components/firefoxview/content/recently-closed-empty.svg new file mode 100644 index 0000000000..014296bae8 --- /dev/null +++ b/browser/components/firefoxview/content/recently-closed-empty.svg @@ -0,0 +1,12 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" width="92" height="70" fill="none"> + <path fill="#FF7139" d="M41.696 54.129c-11.343-5.896-34.162 2.764-37.351-5.896-2.333-6.384 9.734-8.735 3.487-22.05C1.585 12.865 28.38 6.125 37.586 11.88c9.206 5.754 28.576-1.681 40.112 4.892 5.344 3.063 9.418 8.945 6.472 13.022-1.219 1.685-3.482 2.821-4.208 4.714-1.415 3.692 4.36 10.718 4.208 14.689-.715 18.664-31.13 10.827-42.474 4.932Z" fill-opacity="context-fill-opacity"/> + <path fill="#FF8A50" stroke="context-stroke" stroke-width=".917" d="m20.006 38.366 11.777-31.8a3.21 3.21 0 0 1 4.108-1.901l27.506 10.011a3.21 3.21 0 0 1 1.918 4.114L53.724 50.636c-.088.241-.222.43-.35.534a.387.387 0 0 1-.138.079.085.085 0 0 1-.02.003L20 39.163a.086.086 0 0 1-.013-.015.386.386 0 0 1-.054-.149 1.257 1.257 0 0 1 .074-.633Zm33.208 12.887h.002-.002Z"/> + <path fill="context-fill" stroke="context-stroke" stroke-width=".917" d="M20.32 37.506v-.001L31 8.768 64.523 20.97 54.037 49.777a.774.774 0 0 1-.268.372c-.099.067-.154.054-.165.05L20.381 38.106c-.011-.004-.062-.029-.095-.144a.774.774 0 0 1 .034-.456Z"/> + <rect width="9.208" height="1.927" x="34.878" y="6.32" fill="context-fill" stroke="context-stroke" stroke-width=".5" rx=".25" transform="rotate(20 34.878 6.32)"/> + <rect width="9.208" height="1.927" x="45.714" y="10.336" fill="context-fill" stroke="context-stroke" stroke-width=".5" rx=".25" transform="rotate(20 45.714 10.336)"/> + <path fill="context-fill" stroke="context-stroke" d="M42.334 24.818H64a.5.5 0 0 1 .5.5V57a.5.5 0 0 1-.5.5H20a.5.5 0 0 1-.5-.5V20a.5.5 0 0 1 .5-.5h14.59a.5.5 0 0 1 .3.1l6.543 4.917c.26.196.576.301.9.301Z"/> + <path stroke="context-stroke" stroke-linecap="round" d="M63.5 57.5h17M59.5 60.5h13"/> +</svg> diff --git a/browser/components/firefoxview/content/tab-pickup-empty.svg b/browser/components/firefoxview/content/tab-pickup-empty.svg new file mode 100644 index 0000000000..f1c4815120 --- /dev/null +++ b/browser/components/firefoxview/content/tab-pickup-empty.svg @@ -0,0 +1,18 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" width="86" height="70" fill="none"> + <rect width="38.569" height="62.569" x="-.716" y=".716" fill="context-fill" stroke="context-stroke" stroke-width="1.431" rx="9.451" transform="matrix(-1 0 0 1 69.569 0)"/> + <path fill="#FF7139" d="M71.638 11.736c0 3.758 10.574 19.057 11.98 24.351 3.844 14.47-.388 33.529-11.98 33.529-12.387 0-24.598-9.738-32.306-12C22.492 52.674-4.603 56.204.669 40.675c0-4.457 14.39-10.284 18.98-17.646 4.339-6.959 8.611-15.514 11.614-18 10.238-8.47 11.675-1.135 22.449.001 2.357.249 17.925-6.704 17.925 6.706Z" fill-opacity="context-fill-opacity"/> + <g clip-path="url(#a)"> + <rect width="32" height="56" x="35" y="4" fill="context-fill" rx="5.981"/> + <path fill="#FF8A50" stroke="context-stroke" stroke-width=".895" d="M43 6.89a.89.89 0 0 1 .89-.89h14.22a.89.89 0 0 1 .89.89v.22a.89.89 0 0 1-.89.89H43.89a.89.89 0 0 1-.89-.89v-.22ZM51 58.447a3.447 3.447 0 1 0 0-6.894 3.447 3.447 0 0 0 0 6.894Z"/> + </g> + <rect width="33.342" height="57.342" x="34.329" y="3.329" stroke="context-stroke" stroke-width="1.342" rx="6.652"/> + <path stroke="context-stroke" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.123" d="M57 63.3h27.734M58 68h18.787M27 66h18.787"/> + <defs> + <clipPath id="a"> + <rect width="32" height="56" x="35" y="4" fill="context-fill" rx="5.981"/> + </clipPath> + </defs> +</svg> diff --git a/browser/components/firefoxview/firefox-view-notification-manager.sys.mjs b/browser/components/firefoxview/firefox-view-notification-manager.sys.mjs new file mode 100644 index 0000000000..97bac34360 --- /dev/null +++ b/browser/components/firefoxview/firefox-view-notification-manager.sys.mjs @@ -0,0 +1,112 @@ +/* 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/. */ + +/** + * This module exports the FirefoxViewNotificationManager singleton, which manages the notification state + * for the Firefox View button + */ + +const RECENT_TABS_SYNC = "services.sync.lastTabFetch"; +const SHOULD_NOTIFY_FOR_TABS = "browser.tabs.firefox-view.notify-for-tabs"; +const lazy = {}; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +XPCOMUtils.defineLazyModuleGetters(lazy, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", + SyncedTabs: "resource://services-sync/SyncedTabs.jsm", +}); + +export const FirefoxViewNotificationManager = new (class { + #currentlyShowing; + constructor() { + XPCOMUtils.defineLazyPreferenceGetter( + this, + "lastTabFetch", + RECENT_TABS_SYNC, + 0, + () => { + this.handleTabSync(); + } + ); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "shouldNotifyForTabs", + SHOULD_NOTIFY_FOR_TABS, + false + ); + // Need to access the pref variable for the observer to start observing + // See the defineLazyPreferenceGetter function header + this.lastTabFetch; + + Services.obs.addObserver(this, "firefoxview-notification-dot-update"); + + this.#currentlyShowing = false; + } + + async handleTabSync() { + if (!this.shouldNotifyForTabs) { + return; + } + let newSyncedTabs = await lazy.SyncedTabs.getRecentTabs(3); + this.#currentlyShowing = this.tabsListChanged(newSyncedTabs); + this.showNotificationDot(); + this.syncedTabs = newSyncedTabs; + } + + showNotificationDot() { + if (this.#currentlyShowing) { + Services.obs.notifyObservers( + null, + "firefoxview-notification-dot-update", + "true" + ); + } + } + + observe(sub, topic, data) { + if (topic === "firefoxview-notification-dot-update" && data === "false") { + this.#currentlyShowing = false; + } + } + + tabsListChanged(newTabs) { + // The first time the tabs list is changed this.tabs is undefined because we haven't synced yet. + // We don't want to show the badge here because it's not an actual change, + // we are just syncing for the first time. + if (!this.syncedTabs) { + return false; + } + + // We loop through all windows to see if any window has currentURI "about:firefoxview" and + // the window is visible because we don't want to show the notification badge in that case + for (let window of lazy.BrowserWindowTracker.orderedWindows) { + // if the url is "about:firefoxview" and the window visible we don't want to show the notification badge + if ( + window.FirefoxViewHandler.tab?.selected && + !window.isFullyOccluded && + window.windowState !== window.STATE_MINIMIZED + ) { + return false; + } + } + + if (newTabs.length > this.syncedTabs.length) { + return true; + } + for (let i = 0; i < newTabs.length; i++) { + let newTab = newTabs[i]; + let oldTab = this.syncedTabs[i]; + + if (newTab?.url !== oldTab?.url) { + return true; + } + } + return false; + } + + shouldNotificationDotBeShowing() { + return this.#currentlyShowing; + } +})(); diff --git a/browser/components/firefoxview/firefox-view-tabs-setup-manager.sys.mjs b/browser/components/firefoxview/firefox-view-tabs-setup-manager.sys.mjs new file mode 100644 index 0000000000..4b925b758b --- /dev/null +++ b/browser/components/firefoxview/firefox-view-tabs-setup-manager.sys.mjs @@ -0,0 +1,604 @@ +/* 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/. */ + +/** + * This module exports the TabsSetupFlowManager singleton, which manages the state and + * diverse inputs which drive the Firefox View synced tabs setup flow + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Log: "resource://gre/modules/Log.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + UIState: "resource://services-sync/UIState.jsm", + SyncedTabs: "resource://services-sync/SyncedTabs.jsm", + Weave: "resource://services-sync/main.js", +}); + +XPCOMUtils.defineLazyGetter(lazy, "syncUtils", () => { + return ChromeUtils.import("resource://services-sync/util.js").Utils; +}); + +XPCOMUtils.defineLazyGetter(lazy, "fxAccounts", () => { + return ChromeUtils.import( + "resource://gre/modules/FxAccounts.jsm" + ).getFxAccountsSingleton(); +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gNetworkLinkService", + "@mozilla.org/network/network-link-service;1", + "nsINetworkLinkService" +); + +const SYNC_TABS_PREF = "services.sync.engine.tabs"; +const TOPIC_TABS_CHANGED = "services.sync.tabs.changed"; +const MOBILE_PROMO_DISMISSED_PREF = + "browser.tabs.firefox-view.mobilePromo.dismissed"; +const LOGGING_PREF = "browser.tabs.firefox-view.logLevel"; +const TOPIC_SETUPSTATE_CHANGED = "firefox-view.setupstate.changed"; +const TOPIC_DEVICELIST_UPDATED = "fxaccounts:devicelist_updated"; +const NETWORK_STATUS_CHANGED = "network:offline-status-changed"; +const SYNC_SERVICE_ERROR = "weave:service:sync:error"; +const FXA_ENABLED = "identity.fxaccounts.enabled"; +const FXA_DEVICE_CONNECTED = "fxaccounts:device_connected"; +const FXA_DEVICE_DISCONNECTED = "fxaccounts:device_disconnected"; +const SYNC_SERVICE_FINISHED = "weave:service:sync:finish"; +const PRIMARY_PASSWORD_UNLOCKED = "passwordmgr-crypto-login"; +const TAB_PICKUP_OPEN_STATE_PREF = + "browser.tabs.firefox-view.ui-state.tab-pickup.open"; + +function openTabInWindow(window, url) { + const { + switchToTabHavingURI, + } = window.docShell.chromeEventHandler.ownerGlobal; + switchToTabHavingURI(url, true, {}); +} + +export const TabsSetupFlowManager = new (class { + constructor() { + this.QueryInterface = ChromeUtils.generateQI(["nsIObserver"]); + + this.setupState = new Map(); + this.resetInternalState(); + this._currentSetupStateName = ""; + this.networkIsOnline = + lazy.gNetworkLinkService.linkStatusKnown && + lazy.gNetworkLinkService.isLinkUp; + this.syncIsWorking = true; + this.syncIsConnected = lazy.UIState.get().syncEnabled; + this.didFxaTabOpen = false; + + this.registerSetupState({ + uiStateIndex: 0, + name: "error-state", + exitConditions: () => { + const fxaStatus = lazy.UIState.get().status; + return ( + this.networkIsOnline && + (this.syncIsWorking || this.syncHasWorked) && + !Services.prefs.prefIsLocked(FXA_ENABLED) && + // it's an error for sync to not be connected if we are signed-in, + // or for sync to be connected if the FxA status is "login_failed", + // which can happen if a user updates their password on another device + ((!this.syncIsConnected && + fxaStatus !== lazy.UIState.STATUS_SIGNED_IN) || + (this.syncIsConnected && + fxaStatus !== lazy.UIState.STATUS_LOGIN_FAILED)) && + // We treat a locked primary password as an error if we are signed-in. + // If the user dismisses the prompt to unlock, they can use the "Try again" button to prompt again + (!this.isPrimaryPasswordLocked || !this.fxaSignedIn) + ); + }, + }); + this.registerSetupState({ + uiStateIndex: 1, + name: "not-signed-in", + exitConditions: () => { + return this.fxaSignedIn; + }, + }); + this.registerSetupState({ + uiStateIndex: 2, + name: "connect-secondary-device", + exitConditions: () => { + return this.secondaryDeviceConnected; + }, + }); + this.registerSetupState({ + uiStateIndex: 3, + name: "disabled-tab-sync", + exitConditions: () => { + return this.syncTabsPrefEnabled; + }, + }); + this.registerSetupState({ + uiStateIndex: 4, + name: "synced-tabs-loaded", + exitConditions: () => { + // This is the end state + return false; + }, + }); + + Services.obs.addObserver(this, lazy.UIState.ON_UPDATE); + Services.obs.addObserver(this, TOPIC_DEVICELIST_UPDATED); + Services.obs.addObserver(this, NETWORK_STATUS_CHANGED); + Services.obs.addObserver(this, SYNC_SERVICE_ERROR); + Services.obs.addObserver(this, SYNC_SERVICE_FINISHED); + Services.obs.addObserver(this, TOPIC_TABS_CHANGED); + Services.obs.addObserver(this, PRIMARY_PASSWORD_UNLOCKED); + Services.obs.addObserver(this, FXA_DEVICE_CONNECTED); + Services.obs.addObserver(this, FXA_DEVICE_DISCONNECTED); + + // this.syncTabsPrefEnabled will track the value of the tabs pref + XPCOMUtils.defineLazyPreferenceGetter( + this, + "syncTabsPrefEnabled", + SYNC_TABS_PREF, + false, + () => { + this.maybeUpdateUI(true); + } + ); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "mobilePromoDismissedPref", + MOBILE_PROMO_DISMISSED_PREF, + false, + () => { + this.maybeUpdateUI(true); + } + ); + + this._lastFxASignedIn = this.fxaSignedIn; + this.logger.debug( + "TabsSetupFlowManager constructor, fxaSignedIn:", + this._lastFxASignedIn + ); + this.onSignedInChange(); + } + + resetInternalState() { + // assign initial values for all the managed internal properties + delete this._lastFxASignedIn; + this._currentSetupStateName = "not-signed-in"; + this._shouldShowSuccessConfirmation = false; + this._didShowMobilePromo = false; + this._waitingForTabs = false; + + this.syncHasWorked = false; + + // keep track of what is connected so we can respond to changes + this._deviceStateSnapshot = { + mobileDeviceConnected: this.mobileDeviceConnected, + secondaryDeviceConnected: this.secondaryDeviceConnected, + }; + } + + get isPrimaryPasswordLocked() { + return lazy.syncUtils.mpLocked(); + } + + getErrorType() { + // this ordering is important for dealing with multiple errors at once + const errorStates = { + "network-offline": !this.networkIsOnline, + "fxa-admin-disabled": Services.prefs.prefIsLocked(FXA_ENABLED), + "password-locked": this.isPrimaryPasswordLocked, + "signed-out": + lazy.UIState.get().status === lazy.UIState.STATUS_LOGIN_FAILED, + "sync-disconnected": !this.syncIsConnected, + "sync-error": !this.syncIsWorking && !this.syncHasWorked, + }; + + for (let [type, value] of Object.entries(errorStates)) { + if (value) { + return type; + } + } + return null; + } + + uninit() { + Services.obs.removeObserver(this, lazy.UIState.ON_UPDATE); + Services.obs.removeObserver(this, TOPIC_DEVICELIST_UPDATED); + Services.obs.removeObserver(this, NETWORK_STATUS_CHANGED); + Services.obs.removeObserver(this, SYNC_SERVICE_ERROR); + Services.obs.removeObserver(this, SYNC_SERVICE_FINISHED); + Services.obs.removeObserver(this, TOPIC_TABS_CHANGED); + Services.obs.removeObserver(this, PRIMARY_PASSWORD_UNLOCKED); + Services.obs.removeObserver(this, FXA_DEVICE_CONNECTED); + Services.obs.removeObserver(this, FXA_DEVICE_DISCONNECTED); + } + get currentSetupState() { + return this.setupState.get(this._currentSetupStateName); + } + get isTabSyncSetupComplete() { + return this.currentSetupState.uiStateIndex >= 4; + } + get uiStateIndex() { + return this.currentSetupState.uiStateIndex; + } + get fxaSignedIn() { + let { UIState } = lazy; + let syncState = UIState.get(); + return ( + UIState.isReady() && + syncState.status === UIState.STATUS_SIGNED_IN && + // syncEnabled just checks the "services.sync.username" pref has a value + syncState.syncEnabled + ); + } + get secondaryDeviceConnected() { + if (!this.fxaSignedIn) { + return false; + } + let recentDevices = lazy.fxAccounts.device?.recentDeviceList?.length; + return recentDevices > 1; + } + get mobileDeviceConnected() { + if (!this.fxaSignedIn) { + return false; + } + let mobileClients = lazy.fxAccounts.device.recentDeviceList?.filter( + device => device.type == "mobile" || device.type == "tablet" + ); + return mobileClients?.length > 0; + } + get shouldShowMobilePromo() { + return ( + this.syncIsConnected && + this.fxaSignedIn && + this.currentSetupState.uiStateIndex >= 4 && + !this.mobileDeviceConnected && + !this.mobilePromoDismissedPref + ); + } + get shouldShowMobileConnectedSuccess() { + return ( + this.currentSetupState.uiStateIndex >= 3 && + this._shouldShowSuccessConfirmation && + this.mobileDeviceConnected + ); + } + get logger() { + if (!this._log) { + let setupLog = lazy.Log.repository.getLogger("FirefoxView.TabsSetup"); + setupLog.manageLevelFromPref(LOGGING_PREF); + setupLog.addAppender( + new lazy.Log.ConsoleAppender(new lazy.Log.BasicFormatter()) + ); + this._log = setupLog; + } + return this._log; + } + + registerSetupState(state) { + this.setupState.set(state.name, state); + } + + async observe(subject, topic, data) { + switch (topic) { + case lazy.UIState.ON_UPDATE: + this.logger.debug("Handling UIState update"); + this.syncIsConnected = lazy.UIState.get().syncEnabled; + if (this._lastFxASignedIn !== this.fxaSignedIn) { + this.onSignedInChange(); + } else { + this.maybeUpdateUI(); + } + this._lastFxASignedIn = this.fxaSignedIn; + break; + case TOPIC_DEVICELIST_UPDATED: + this.logger.debug("Handling observer notification:", topic, data); + if (await this.refreshDevices()) { + this.logger.debug( + "refreshDevices made changes, calling maybeUpdateUI" + ); + this.maybeUpdateUI(true); + } + break; + case FXA_DEVICE_CONNECTED: + case FXA_DEVICE_DISCONNECTED: + await lazy.fxAccounts.device.refreshDeviceList({ ignoreCached: true }); + this.maybeUpdateUI(true); + break; + case SYNC_SERVICE_ERROR: + this.logger.debug(`Handling ${SYNC_SERVICE_ERROR}`); + if (lazy.UIState.get().status == lazy.UIState.STATUS_SIGNED_IN) { + this._waitingForTabs = false; + this.syncIsWorking = false; + this.maybeUpdateUI(true); + } + break; + case NETWORK_STATUS_CHANGED: + this.networkIsOnline = data == "online"; + this._waitingForTabs = false; + this.maybeUpdateUI(true); + break; + case SYNC_SERVICE_FINISHED: + this.logger.debug(`Handling ${SYNC_SERVICE_FINISHED}`); + this._waitingForTabs = false; + if (!this.syncIsWorking) { + this.syncIsWorking = true; + this.syncHasWorked = true; + } + this.maybeUpdateUI(true); + break; + case TOPIC_TABS_CHANGED: + this.stopWaitingForTabs(); + break; + case PRIMARY_PASSWORD_UNLOCKED: + this.logger.debug(`Handling ${PRIMARY_PASSWORD_UNLOCKED}`); + this.tryToClearError(); + break; + } + } + + get waitingForTabs() { + return ( + // signed in & at least 1 other device is sycning indicates there's something to wait for + this.secondaryDeviceConnected && + // last recent tabs request came back empty and we've not had a sync finish (or error) yet + this._waitingForTabs + ); + } + + startWaitingForTabs() { + if (!this._waitingForTabs) { + this._waitingForTabs = true; + Services.obs.notifyObservers(null, TOPIC_SETUPSTATE_CHANGED); + } + } + + stopWaitingForTabs() { + if (this._waitingForTabs) { + this._waitingForTabs = false; + Services.obs.notifyObservers(null, TOPIC_SETUPSTATE_CHANGED); + } + } + + async onSignedInChange() { + this.logger.debug("onSignedInChange, fxaSignedIn:", this.fxaSignedIn); + // update UI to make the state change + this.maybeUpdateUI(true); + if (!this.fxaSignedIn) { + // As we just signed out, ensure the waiting flag is reset for next time around + this._waitingForTabs = false; + return; + } + + // Set Tab pickup open state pref to true when signing in + Services.prefs.setBoolPref(TAB_PICKUP_OPEN_STATE_PREF, true); + + // Now we need to figure out if we have recently synced tabs to show + // Or, if we are going to need to trigger a tab sync for them + const recentTabs = await lazy.SyncedTabs.getRecentTabs(50); + + if (!this.fxaSignedIn) { + // We got signed-out in the meantime. We should get an ON_UPDATE which will put us + // back in the right state, so we just do nothing here + return; + } + + // When SyncedTabs has resolved the getRecentTabs promise, + // we also know we can update devices-related internal state + if (await this.refreshDevices()) { + this.logger.debug( + "onSignedInChange, after refreshDevices, calling maybeUpdateUI" + ); + // give the UI an opportunity to update as secondaryDeviceConnected or + // mobileDeviceConnected have changed value + this.maybeUpdateUI(true); + } + + // If we can't get recent tabs, we need to trigger a request for them + const tabSyncNeeded = !recentTabs?.length; + this.logger.debug("onSignedInChange, tabSyncNeeded:", tabSyncNeeded); + + if (tabSyncNeeded) { + this.startWaitingForTabs(); + this.logger.debug( + "isPrimaryPasswordLocked:", + this.isPrimaryPasswordLocked + ); + this.logger.debug("onSignedInChange, no recentTabs, calling syncTabs"); + // If the syncTabs call rejects or resolves false we need to clear the waiting + // flag and update UI + this.syncTabs() + .catch(ex => { + this.logger.debug("onSignedInChange, syncTabs rejected:", ex); + this.stopWaitingForTabs(); + }) + .then(willSync => { + if (!willSync) { + this.logger.debug("onSignedInChange, no tab sync expected"); + this.stopWaitingForTabs(); + } + }); + } + } + + async refreshDevices() { + // If current device not found in recent device list, refresh device list + if ( + !lazy.fxAccounts.device.recentDeviceList?.some( + device => device.isCurrentDevice + ) + ) { + await lazy.fxAccounts.device.refreshDeviceList({ ignoreCached: true }); + } + + // compare new values to the previous values + const mobileDeviceConnected = this.mobileDeviceConnected; + const secondaryDeviceConnected = this.secondaryDeviceConnected; + + this.logger.debug( + `refreshDevices, mobileDeviceConnected: ${mobileDeviceConnected}, `, + `secondaryDeviceConnected: ${secondaryDeviceConnected}` + ); + + let didDeviceStateChange = + this._deviceStateSnapshot.mobileDeviceConnected != + mobileDeviceConnected || + this._deviceStateSnapshot.secondaryDeviceConnected != + secondaryDeviceConnected; + if ( + mobileDeviceConnected && + !this._deviceStateSnapshot.mobileDeviceConnected + ) { + // a mobile device was added, show success if we previously showed the promo + this._shouldShowSuccessConfirmation = this._didShowMobilePromo; + } else if ( + !mobileDeviceConnected && + this._deviceStateSnapshot.mobileDeviceConnected + ) { + // no mobile device connected now, reset + Services.prefs.clearUserPref(MOBILE_PROMO_DISMISSED_PREF); + this._shouldShowSuccessConfirmation = false; + } + this._deviceStateSnapshot = { + mobileDeviceConnected, + secondaryDeviceConnected, + }; + if (didDeviceStateChange) { + this.logger.debug("refreshDevices: device state did change"); + if (!secondaryDeviceConnected) { + this.logger.debug( + "We lost a device, now claim sync hasn't worked before." + ); + this.syncHasWorked = false; + } + } else { + this.logger.debug("refreshDevices: no device state change"); + } + return didDeviceStateChange; + } + + maybeUpdateUI(forceUpdate = false) { + let nextSetupStateName = this._currentSetupStateName; + let errorState = null; + let stateChanged = false; + + // state transition conditions + for (let state of this.setupState.values()) { + nextSetupStateName = state.name; + if (!state.exitConditions()) { + this.logger.debug( + "maybeUpdateUI, conditions not met to exit state: ", + nextSetupStateName + ); + break; + } + } + + let setupState = this.currentSetupState; + const state = this.setupState.get(nextSetupStateName); + const uiStateIndex = state.uiStateIndex; + + if ( + uiStateIndex == 0 || + nextSetupStateName != this._currentSetupStateName + ) { + setupState = state; + this._currentSetupStateName = nextSetupStateName; + stateChanged = true; + } + this.logger.debug( + "maybeUpdateUI, will notify update?:", + stateChanged, + forceUpdate + ); + if (stateChanged || forceUpdate) { + if (this.shouldShowMobilePromo) { + this._didShowMobilePromo = true; + } + if (uiStateIndex == 0) { + errorState = this.getErrorType(); + this.logger.debug("maybeUpdateUI, in error state:", errorState); + } + Services.obs.notifyObservers(null, TOPIC_SETUPSTATE_CHANGED, errorState); + } + if ("function" == typeof setupState.enter) { + setupState.enter(); + } + } + + dismissMobilePromo() { + Services.prefs.setBoolPref(MOBILE_PROMO_DISMISSED_PREF, true); + } + + dismissMobileConfirmation() { + this._shouldShowSuccessConfirmation = false; + this._didShowMobilePromo = false; + this.maybeUpdateUI(true); + } + + async openFxASignup(window) { + if (!(await lazy.fxAccounts.constructor.canConnectAccount())) { + return; + } + const url = await lazy.fxAccounts.constructor.config.promiseConnectAccountURI( + "fx-view" + ); + this.didFxaTabOpen = true; + openTabInWindow(window, url, true); + Services.telemetry.recordEvent("firefoxview", "fxa_continue", "sync", null); + } + + async openFxAPairDevice(window) { + const url = await lazy.fxAccounts.constructor.config.promisePairingURI({ + entrypoint: "fx-view", + }); + this.didFxaTabOpen = true; + openTabInWindow(window, url, true); + Services.telemetry.recordEvent("firefoxview", "fxa_mobile", "sync", null, { + has_devices: this.secondaryDeviceConnected.toString(), + }); + } + + syncOpenTabs(containerElem) { + // Flip the pref on. + // The observer should trigger re-evaluating state and advance to next step + Services.prefs.setBoolPref(SYNC_TABS_PREF, true); + } + + async syncOnPageReload() { + if (lazy.UIState.isReady() && this.fxaSignedIn) { + this.startWaitingForTabs(); + await this.syncTabs(true); + } + } + + tryToClearError() { + if (lazy.UIState.isReady() && this.fxaSignedIn) { + this.startWaitingForTabs(); + Services.tm.dispatchToMainThread(() => { + this.logger.debug("tryToClearError: triggering new tab sync"); + this.startFullTabsSync(); + }); + } else { + this.logger.debug( + `tryToClearError: unable to sync, isReady: ${lazy.UIState.isReady()}, fxaSignedIn: ${ + this.fxaSignedIn + }` + ); + } + } + // For easy overriding in tests + syncTabs(force = false) { + return lazy.SyncedTabs.syncTabs(force); + } + + startFullTabsSync() { + lazy.Weave.Service.sync({ why: "tabs", engines: ["tabs"] }); + } +})(); diff --git a/browser/components/firefoxview/firefoxview.css b/browser/components/firefoxview/firefoxview.css new file mode 100644 index 0000000000..dbb3024a2b --- /dev/null +++ b/browser/components/firefoxview/firefoxview.css @@ -0,0 +1,1049 @@ +/* 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/. */ + +:host, +:root { + --content-area-padding-inline: 24px; + --content-area-padding-block: 16px; + --header-spacing: 40px; + --footer-spacing: 80px; + + --success-fill-color: #2AC3A2; + --success-background-color: #87FFD1; + --success-box-text-color: #15141A; + + --details-grid-column: 1; + --recently-closed-tabs-grid-row: 2; + --colorways-grid-column: 2; + --colorways-grid-row: 1 / 3; + + --colorways-figure-size: 225px; + --colorways-figure-margin: 0 0 1.5em; + --colorways-grid-template-areas: + "colorways-figure" + "colorways-header" + "colorways-description" + "colorways-button"; + --colorways-grid-template-columns: auto; + --colorways-grid-template-rows: auto auto auto 1fr; + --colorways-figure-display: flex; + --colorways-header-flex-direction: column; + + --info-icon-background-color: #0090ED; +} + +:root { + /* align the base font-size on root element with that of <body> + so rem-based layout widths and break-points behave predictably */ + font-size: 15px; + /* override --in-content-page-background from common-shared.css */ + background-color: transparent; +} + +body { + --fxview-background-color: var(--newtab-background-color, var(--in-content-page-background)); + --fxview-element-background-hover: color-mix(in srgb, var(--fxview-background-color) 90%, currentColor); + --fxview-element-background-active: color-mix(in srgb, var(--fxview-background-color) 80%, currentColor); + --fxview-text-primary-color: var(--newtab-text-primary-color, var(--in-content-page-color)); + --fxview-text-color-hover: var(--newtab-text-primary-color); + --fxview-contrast-border: color-mix(in hsl, currentColor 45%, transparent); + --fxview-extra-contrast-border: color-mix(in hsl, currentColor 85%, transparent); + --in-content-zap-gradient: linear-gradient(var(--fxview-extra-contrast-border), var(--fxview-extra-contrast-border)); + --card-border-zap-gradient: var(--in-content-zap-gradient); + --fxview-text-secondary-color: color-mix(in srgb, currentColor 70%, transparent); + --newtab-background-color-secondary: #FFF; + + /* ensure utility button hover states match those of the rest of the page */ + --in-content-button-background-hover: var(--fxview-element-background-hover); + --in-content-button-background-active: var(--fxview-element-background-active); + --in-content-button-text-color-hover: var(--fxview-text-color-hover); + + display: flex; + justify-content: center; + padding-block: var(--header-spacing) var(--footer-spacing); + padding-inline: var(--content-area-padding-inline); + max-width: 96rem; + margin-inline: auto; + background-color: var(--fxview-background-color); + color: var(--newtab-text-primary-color); +} + +body:not([lwt-newtab]) { + --in-content-zap-gradient: linear-gradient(90deg, #9059FF 0%, #FF4AA2 52.08%, #FFBD4F 100%); +} + +@media (prefers-color-scheme: dark) { + body { + --fxview-element-background-hover: color-mix(in srgb, var(--fxview-background-color) 80%, white); + --fxview-element-background-active: color-mix(in srgb, var(--fxview-background-color) 60%, white); + --newtab-background-color-secondary: #42414d; + } +} + +@media (prefers-contrast) { + body { + --fxview-element-background-hover: ButtonText; + --fxview-element-background-active: ButtonText; + --fxview-text-color-hover: ButtonFace; + --fxview-text-secondary-color: currentColor; + } +} + +h1 { + color: var(--fxview-text-primary-color); + font-weight: 600; + font-size: 1.5em; +} + +.content-container { + padding-inline: var(--content-area-padding-inline); + padding-block: var(--content-area-padding-block); +} + +#logo-container { + flex: 0 0 auto; +} + +body > main { + flex: 1 1 auto; + display: grid; + grid-template-columns: 2fr 1fr; + grid-template-rows: max-content 1fr; +} + +body > main > details { + grid-column: var(--details-grid-column); +} + +@media (max-width: 76rem) { + :host, + :root { + --content-area-padding-inline: 12px; + } + .brand-logo > .brand-feature-name { + display: none; + } +} + +@media (max-width: 65rem) { + :root { + --recently-closed-tabs-grid-row: 3; + --details-grid-column: 1 / -1; + --colorways-grid-column: 1 / -1; + --colorways-grid-row: 2; + + --colorways-grid-template-areas: + "colorways-header colorways-figure" + "colorways-description colorways-figure" + "colorways-button colorways-figure"; + --colorways-grid-template-columns: 1fr var(--colorways-figure-size); + --colorways-grid-template-rows: min-content min-content 1fr; + --colorways-header-flex-direction: row; + --colorways-figure-margin: 0; + } +} + +@media (max-width: 45rem) { + :host, + :root { + --header-spacing: 16px; + --footer-spacing: 16px; + --colorways-grid-template-areas: + "colorways-header" + "colorways-description" + "colorways-button"; + --colorways-grid-template-columns: auto; + --colorways-grid-template-rows: auto; + --colorways-figure-display: none; + --colorways-header-flex-direction: column; + } +} + +@media (max-width: 28rem) { + body { + flex-wrap: wrap; + } +} + +.brand-logo { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 1.75em; + white-space: nowrap; +} + +.brand-logo > .brand-icon { + display: inline-block; + background: url("chrome://branding/content/about-logo.png") no-repeat center; + background-size: 32px; + min-width: 40px; + height: 32px; +} + +.brand-logo > .brand-feature-name { + margin-inline-start: 8px; + flex: 1 1 auto; + font-weight: 600; +} + +#colorways:not([hidden]) { + display: grid; + grid-column: var(--colorways-grid-column); + grid-row: var(--colorways-grid-row); + grid-template-areas: var(--colorways-grid-template-areas); + grid-template-columns: var(--colorways-grid-template-columns); + grid-template-rows: var(--colorways-grid-template-rows); + justify-items: start; + align-items: start; + padding-inline: calc(2 * var(--content-area-padding-inline)); + padding-block: calc(2 * var(--content-area-padding-block)); +} + +#colorways > header { + grid-area: colorways-header; + display: flex; + flex-direction: var(--colorways-header-flex-direction); + align-items: flex-start; + flex-wrap: wrap; +} + +#colorways-collection-description { + grid-area: colorways-description; +} + +#colorways-collection-description, +#colorways-button { + margin: 0.4em 0; +} + +#colorways-button { + grid-area: colorways-button; +} + +#colorways-collection-title { + margin: 0; + margin-top: 0.3em; + margin-inline-end: 0.5em; + padding: 0; +} + +#colorways-collection-expiry-date { + display: inline-block; + background: var(--card-border-zap-gradient); + background-origin: border-box; + border: 1px solid transparent; + border-radius: 1.5em; + margin: 0.8em 0; +} + +#colorways-collection-expiry-date > span { + display: inline-block; + background: var(--fxview-background-color); + border-radius: 1.5em; + padding: .3em 1em; +} + +#colorways > figure { + display: var(--colorways-figure-display); + grid-area: colorways-figure; + align-items: center; + justify-content: center; + width: var(--colorways-figure-size); + height: var(--colorways-figure-size); + margin: var(--colorways-figure-margin); +} + +#colorways-collection-figure { + max-width: var(--colorways-figure-size); + max-height: var(--colorways-figure-size); + object-fit: scale-down; +} + +[hidden] { + display: none !important; +} + +button.ghost-button, +button.ghost-button:not(.semi-transparent):enabled:is(:hover, :active) { + color: inherit; +} + +@media (prefers-contrast) { + button.ghost-button:not(.semi-transparent):enabled:is(:hover, :active) { + background-color: ButtonText; + color: ButtonFace; + } +} + +button.primary { + white-space: nowrap; + min-width: fit-content; +} + +button.close { + background-image: url(chrome://global/skin/icons/close.svg); + -moz-context-properties: fill; + fill: currentColor; +} + +.card, +.synced-tab-a, +.synced-tab-li-placeholder, +.empty-container { + background-color: var(--newtab-background-color-secondary); + border: 1px solid var(--fxview-contrast-border); +} + +#collapsible-tabs-container, +#tabpickup-tabs-container { + margin-block-start: 0.5em; +} + +.empty-container { + border-radius: 4px; +} + +.error-state { + text-align: center; + padding-block: 0 1.3em; + padding-inline: 1em; + border: 1px solid var(--fxview-contrast-border); + border-radius: 4px; +} + +.error-state > h3 { + font-weight: 600; + display: inline-block; + margin-bottom: 0; +} + +.placeholder-content { + color: var(--fxview-text-secondary-color); + display: flex; + padding: 1.8em 1.1em; +} + +#recently-closed-empty-image, +#tab-pickup-empty-image { + margin-inline-end: 1.1em; + -moz-context-properties: fill, stroke, fill-opacity; + fill: var(--fxview-background-color); + stroke: var(--fxview-text-primary-color); + fill-opacity: 0.07; +} + +@media (prefers-color-scheme: dark) { + #recently-closed-empty-image, + #tab-pickup-empty-image { + fill: var(--newtab-background-color-secondary); + fill-opacity: 0.15; + } +} + +.placeholder-text { + margin: 0; +} + +.placeholder-header { + margin-block: 0 0.27em; + font-weight: 600; +} + +.placeholder-body { + margin-block: 0; + line-height: 1.3em; +} + +.page-section-header { + column-gap: 16px; + cursor: pointer; + display: grid; + grid-template-columns: auto 1fr; + grid-template-rows: 1fr auto; + grid-template-areas: + "twisty head" + "none desc"; + list-style-type: none; + position: relative; + z-index: 1; +} + +@media (prefers-contrast) { + .page-section-header { + color: WindowText; + } + .page-section-header:focus-visible { + box-shadow: none; + outline: var(--in-content-focus-outline); + outline-offset: var(--in-content-focus-outline-offset); + } +} + +.page-section-header > h1 { + grid-area: head; + margin: 0; + padding-block: 4px; +} + +/* the twisty is just an ornament; the whole summary parent node is clickable */ +.page-section-header > .twisty { + background-image: url("chrome://global/skin/icons/arrow-right.svg"); + display: inline-block; + grid-area: twisty; + align-self: center; + justify-self: start; + padding-block: 4px; + padding-inline: 8px; + fill: currentColor; + border-radius: 4px; + margin-block: 0; +} + +[dir="rtl"] .page-section-header > .twisty { + background-image: url("chrome://global/skin/icons/arrow-left.svg"); +} + +@media (prefers-contrast) { + .page-section-header > .twisty { + border: 1px solid ButtonText; + } +} + +details[open] > .page-section-header > .twisty { + background-image: url("chrome://global/skin/icons/arrow-down.svg"); +} + +.page-section-header:hover > .twisty { + background-color: var(--fxview-element-background-hover); + color: var(--fxview-text-color-hover); +} + +.page-section-header:hover:active > .twisty { + background-color: var(--fxview-element-background-active); +} + +.page-section-header > .section-description { + grid-area: desc; + margin-block: 4px 8px; +} + +.card-body { + display: flex; + flex-grow: 1; + align-content: space-between; + align-items: center; + gap: 8px; +} +@media only screen and (max-width: 45rem) { + .card-body { + flex-wrap: wrap; + } +} + +.card-body > button.primary { + margin-inline-start: 0; + z-index: 1; +} + +.card-body > .step-content, +.zap-card > button.close { + z-index: 1; +} + +.setup-step { + padding: var(--card-padding); + margin-block: 0.5em 1em; +} + +/* Bug 1770534 - Only use the zap-gradient for built-in, color-neutral themes */ +.zap-card { + border: none; + position: relative; +} +.zap-card::before { + content: ""; + position: absolute; + inset: 0; + border: 1px solid transparent; + border-radius: 4px; + background-origin: border-box; + background-image: var(--card-border-zap-gradient); + mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + mask-composite: exclude; +} + +.setup-step > h2 { + margin-block: 0 8px; +} + +.setup-step > .card-body { + margin-block: 8px; + padding-block: 8px; +} +.setup-step > .card-body > .step-content { + flex: 1 1 auto; +} + +.setup-step > footer { + display: flex; + flex-direction: column; + margin-block: 0 8px; +} + +.step-progress { + background-color: #E0E0E6; + border-radius: 8px; + border-style: none; + height: 8px; + margin-block: 0 8px; + margin-inline: 0 2px; +} + +.step-progress::-moz-progress-bar { + background-color: var(--success-fill-color); + border-radius: 8px; +} + +@media (prefers-contrast) { + .step-progress { + background-color: SelectedItemText; + border: 1px solid SelectedItem; + } + + .step-progress::-moz-progress-bar { + background-color: SelectedItem; + } +} + +.message-box { + display: flex; + align-items: center; + margin-block: 8px; + gap: 8px; +} + +.message-content { + flex: 1 1 auto; +} + +.message-content > .message-header { + font-size: 1em; + margin-block: 0 0.33em; +} + +.message-content > .message-description { + margin-block: 0 0.33em; +} + +.confirmation-message-box { + background-color: var(--success-background-color); + color: var(--success-box-text-color); + border-color: var(--success-fill-color); +} +.confirmation-message-box > .message-content { + text-align: center; +} +.confirmation-message-box > .message-content > .message-header { + font-size: inherit; + display: inline; +} +/* ensure we get the local color values as container doesnt change color with theme */ +.confirmation-message-box > .icon-button { + color: inherit; +} +.confirmation-message-box > button.icon-button:enabled:is(:hover, :active) { + background-color: color-mix(in srgb, var(--success-background-color) 90%, currentColor); +} +@media (prefers-contrast) { + .confirmation-message-box > button.icon-button { + border-color: ButtonText; + } + .confirmation-message-box > button.icon-button:enabled:is(:hover, :active) { + background-color: ButtonText; + color: ButtonFace; + } +} + +#tab-pickup-container { + grid-row: 1; +} + +/* 117px is the total height of the collapsible-tabs-container; setting that size + for the second row stabilizes the layout so it doesn't shift when collapsibled */ +#recently-closed-tabs-container { + grid-row: var(--recently-closed-tabs-grid-row); + display: grid; + grid-template-rows: max-content 117px; +} + +#recently-closed-tabs-container > p { + margin-top: 0; +} + +.synced-tabs-container.loading > .card, +.synced-tabs-container.loading > tab-pickup-list, +.synced-tabs-container.loading > .placeholder-content, +.synced-tabs-container:not(.loading) > .loading-content { + display: none; +} + +.synced-tabs-container > .loading-content { + text-align: center; + color: var(--fxview-text-secondary-color); + margin-top: 40px; + padding: 20px 16px 16px; +} + +.closed-tabs-list { + padding-inline-start: 0; + margin-block-start: 0; + display: grid; + grid-template-columns: min-content repeat(5, 1fr) repeat(2, min-content); + column-gap: 8px; + row-gap: 8px; +} + +.closed-tab-li { + display: grid; + grid-template-columns: subgrid; + grid-column: span 8; + margin-block-end: 0.5em; + border-radius: 4px; + align-items: center; +} + +.closed-tab-li-main { + display: grid; + grid-template-columns: subgrid; + grid-column: span 7; + padding: 0.5em; + cursor: pointer; + align-items: center; + user-select: none; + border-radius: 4px; +} + +@media (prefers-contrast) { + span.closed-tab-li-main, + button.closed-tab-li-dismiss { + color: ButtonText; + border-radius: 4px; + border: 1px solid ButtonText; + } +} + +.closed-tab-li-main:hover { + background-color: var(--fxview-element-background-hover); + color: var(--fxview-text-color-hover); +} + +.closed-tab-li-main:hover .closed-tab-li-title { + text-decoration-line: underline; +} + +.closed-tab-li-main:active { + background-color: var(--fxview-element-background-active); + color: var(--fxview-text-color-hover); +} + +.closed-tab-li-main:focus-visible { + box-shadow: none; + outline: var(--in-content-focus-outline); + outline-offset: var(--in-content-focus-outline-offset); + border-radius: 4px; +} + +.closed-tab-li-title { + padding-inline-start: 10px; + font-weight: 500; + grid-column: span 3; +} + +.closed-tab-li-url { + padding-inline-start: 8px; + text-decoration-line: underline; + grid-column: span 2; +} + +.closed-tab-li-time { + white-space: nowrap; + text-align: end; +} + +.closed-tab-li-dismiss { + background-image: url("chrome://global/skin/icons/close.svg"); + background-repeat: no-repeat; + background-position: center; + background-color: transparent; + color: var(--fxview-text-secondary-color); + -moz-context-properties: fill; + fill: var(--fxview-text-secondary-color); + min-width: 33px; + padding: 0.5em; + margin: 0; + cursor: pointer; + user-select: none; +} + +.closed-tab-li-dismiss:hover { + background-color: var(--in-content-button-background-hover); + fill: var(--in-content-button-text-color-hover); +} + +.synced-tab-a, +.synced-tab-a:hover, +.synced-tab-a:active, +.synced-tab-a:hover:active, +.synced-tab-a:visited { + color: inherit; + text-decoration: none; + height: 100%; +} + +@media (prefers-contrast) { + .synced-tab-a { + border-color: FieldText; + } + .synced-tab-a, + .synced-tab-a:hover, + .synced-tab-a:active, + .synced-tab-a:hover:active, + .synced-tab-a:visited { + color: LinkText; + } + .synced-tab-a:focus-visible { + box-shadow: none; + outline: var(--in-content-focus-outline); + outline-offset: var(--in-content-focus-outline-offset); + } +} + +.closed-tab-li-url, +.closed-tab-li-time, +.synced-tab-li-device, +.synced-tab-li-url, +.synced-tab-li-time { + font-weight: 400; + color: var(--fxview-text-secondary-color); +} + +.closed-tab-li-title, +.closed-tab-li-url, +.synced-tab-li:not(:first-child) > .synced-tab-a > .synced-tab-li-title, +.synced-tab-li-device { + overflow: hidden; +} + +.closed-tab-li-title, +.synced-tab-li:not(:first-child) > .synced-tab-a > .synced-tab-li-title, +.synced-tab-li-device { + text-overflow: ellipsis; + white-space: nowrap; +} + +.synced-tab-li-url, +.closed-tab-li-url { + word-break: break-word; +} + +.synced-tabs-list { + padding: 0; + margin-block-start: 0; + list-style: none; + display: grid; + grid-template-columns: 4fr 4fr; + column-gap: 16px; + row-gap: 8px; + + grid-template-areas: + "first second" + "first third"; +} + +@media only screen and (max-width: 43rem) { + .synced-tabs-list { + grid-template-columns: 1fr; + grid-template-areas: + "first" + "second" + "third"; + } + + body { + flex-flow: column; + } + + #logo-container .brand-logo { + justify-content: center; + } +} + +.synced-tab-a, +.synced-tab-li-placeholder { + box-sizing: border-box; + border-radius: 4px; + padding: 7px; + display: grid; + column-gap: 8px; + row-gap: 2px; + align-items: center; + grid-template-columns: min-content repeat(2, 1fr) minmax(min-content, auto); + grid-template-rows: auto 1fr auto; + grid-template-areas: + "favicon title title title" + "favicon domain domain domain" + "favicon device device time" +} + +.synced-tab-a:hover { + box-shadow: 0px 2px 14px var(--fxview-contrast-border); +} + +.synced-tab-li:not(:first-child) > .synced-tab-a { + align-content: center; +} + +@media only screen and (max-width: 60rem) { + .synced-tab-li > .synced-tab-a, + .synced-tab-li-placeholder { + grid-template-areas: + "favicon title title title" + "favicon domain domain domain" + "favicon device device device"; + } + .synced-tab-li:not(:first-child) > .synced-tab-a > .synced-tab-li-time { + display: none; + } +} + +.synced-tab-li-placeholder { + row-gap: 1em; + grid-template-areas: + "favicon title title title" + "favicon domain domain domain"; + grid-template-rows: auto auto; +} + +.li-placeholder-favicon { + grid-area: favicon; + align-self: start; + width: 16px; + height: 16px; +} + +.li-placeholder-title { + grid-area: title; + height: .8em; + margin-block: .1em; /* simulate line-height */ + width: 100%; +} + +.li-placeholder-domain { + grid-area: domain; + height: .6em; + margin-block: .1em; /* simulate line-height */ + width: 100%; +} + +.li-placeholder-favicon, +.li-placeholder-title, +.li-placeholder-domain { + display: inline-block; + background-color: currentColor; opacity: 0.08; + border-radius: 4px; +} + +.synced-tab-li:first-child { + grid-area: first; +} + +.synced-tab-li:first-child > .synced-tab-a { + padding: 15px; + grid-template-columns: repeat(4, 1fr); + grid-template-rows: auto auto 1fr auto; + grid-template-areas: + "favicon badge badge badge" + "title title title title" + "domain domain domain domain" + "device device device time"; + row-gap: 4px; +} + +.synced-tab-li:nth-child(2) { + grid-area: second; +} + +.synced-tab-li:nth-child(3) { + grid-area: third; +} + +.synced-tab-li-url, +.synced-tab-li-device, +.synced-tab-li:not(:first-child) > .synced-tab-a > .synced-tab-li-title { + font-size: .85em; +} + +.synced-tab-li-time { + font-size: .75em; +} + +.synced-tab-li-url { + text-decoration-line: underline; + grid-area: domain; + align-self: start; +} + +.synced-tab-li-title { + grid-area: title; + font-weight: 500; +} + +.synced-tab-li:first-child > .synced-tab-a > .synced-tab-li-title { + color: inherit; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + align-self: start; +} + +.synced-tab-li-device { + grid-area: device; +} + +.synced-tab-li-time { + grid-area: time; + justify-self: end; + align-self: end; + white-space: nowrap; +} + +.synced-tab-li:first-child > .synced-tab-a > .synced-tab-li-time { + align-self: center; +} + +.synced-tab-li .favicon { + grid-area: favicon; + align-self: start; +} + +@media (prefers-contrast) { + .synced-tab-li .favicon { + color: LinkText; + } +} + +.synced-tab-li .icon { + vertical-align: bottom; + margin-inline-end: 5px; +} + +.icon { + background-position: center center; + background-repeat: no-repeat; + display: inline-block; + -moz-context-properties: fill; + fill: currentColor; +} + +.history { + background-image: url('chrome://browser/skin/history.svg'); +} + +.phone { + background-image: url('chrome://browser/skin/device-phone.svg'); +} + +.desktop { + background-image: url('chrome://browser/skin/device-desktop.svg'); +} + +.tablet { + background-image: url('chrome://browser/skin/device-tablet.svg'); +} + +.synced-tabs { + background-image: url('chrome://browser/skin/synced-tabs.svg'); +} + +.info { + background-image: url('chrome://global/skin/icons/info-filled.svg'); +} + +.error-state > .info { + vertical-align: text-top; + margin-inline-end: 7px; + margin-top: 1px; + color: var(--info-icon-background-color); +} + +.favicon { + background-size: cover; + -moz-context-properties: fill; + fill: currentColor; +} + +.favicon, .icon, .synced-tab-li-favicon { + width: 16px; + height: 16px; +} + +.sync { + background-image: url(chrome://browser/skin/sync.svg); + background-size: cover; + height: 19px; + width: 19px; + color: var(--fxview-text-secondary-color); +} + +@keyframes syncRotate { + from { transform: rotate(0); } + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: no-preference) { + .sync { + animation: syncRotate 0.8s linear infinite; + } +} + +.last-active-badge { + height: 1.25em; + background-color: #E3FFF3; + grid-area: badge; + border-radius: 2em; + justify-self: end; + text-align: center; + padding: 0.3em 1em; + font-size: 0.75em; +} + +.dot { + height: 8px; + width: 8px; + background-color: var(--success-fill-color); + border-radius: 50%; + display: inline-block; +} + +.badge-text { + font-weight: 400; + letter-spacing: 0.02em; + margin-inline-start: 4px; + color: #000000; +} + +@media (prefers-contrast) { + .last-active-badge { + border: 1px solid CanvasText; + background-color: Canvas; + } + .dot { + background-color: FieldText; + } + .badge-text { + color: FieldText; + } +} diff --git a/browser/components/firefoxview/firefoxview.html b/browser/components/firefoxview/firefoxview.html new file mode 100644 index 0000000000..749a8564a4 --- /dev/null +++ b/browser/components/firefoxview/firefoxview.html @@ -0,0 +1,176 @@ +<!-- 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/. --> + +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + <meta http-equiv="Content-Security-Policy" content="default-src resource: chrome:; object-src 'none'; img-src data: chrome:;"> + <meta name="color-scheme" content="light dark"> + <title data-l10n-id="firefoxview-page-title"></title> + <link rel="icon" type="image/png" id="favicon" href="chrome://branding/content/icon32.png"/> + <link rel="localization" href="branding/brand.ftl"> + <link rel="localization" href="browser/branding/brandings.ftl"/> + <link rel="localization" href="browser/branding/sync-brand.ftl"> + <link rel="localization" href="browser/firefoxView.ftl"/> + <link rel="localization" href="browser/colorways.ftl"> + <link rel="localization" href="browser/colorwaycloset.ftl"> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css"> + <link rel="stylesheet" href="chrome://browser/content/firefoxview.css"> + <script type="module" src="chrome://browser/content/tab-pickup-container.mjs"></script> + <script type="module" src="chrome://browser/content/firefoxview.mjs"></script> + <script type="module" src="chrome://browser/content/colorways-card.mjs"></script> + <script type="module" src="chrome://browser/content/recently-closed-tabs.mjs"></script> + <script type="module" src="chrome://browser/content/tab-pickup-list.mjs"></script> + <script src="chrome://browser/content/contentTheme.js"></script> + </head> + + <body> + <div id="logo-container" class="content-container"> + <div class="brand-logo"> + <span class="brand-icon"></span> + <span class="brand-feature-name" data-l10n-id="firefoxview-page-title"></span> + </div> + </div> + <main> + <details class="content-container" is="tab-pickup-container" id="tab-pickup-container" open> + <summary class="page-section-header"> + <span class="twisty icon" data-l10n-id="firefoxview-collapse-button-hide" aria-role="presentation"></span> + <h1 id="collapsible-synced-tabs-header" data-l10n-id="firefoxview-tabpickup-header"></h1> + <p class="section-description" data-l10n-id="firefoxview-tabpickup-description"></p> + </summary> + + <div class="confirmation-message-box message-box card card-no-hover" hidden> + <div class="message-content"> + <h2 data-l10n-id="firefoxview-mobile-confirmation-header" class="message-header"></h2> + <span class="message-description" data-l10n-id="firefoxview-mobile-confirmation-description"></span> + </div> + <button data-action="mobile-confirmation-dismiss" class="close icon-button ghost-button" data-l10n-id="firefoxview-close-button"></button> + </div> + <named-deck class="sync-setup-container" role="region" aria-labelledby="collapsible-synced-tabs-header" id="tabpickup-steps"> + <div name="sync-setup-view0" id="tabpickup-steps-view0" class="card card-no-hover error-state" aria-labelledby="tabpickup-steps-view0-header"> + <icon class="icon info primary"></icon><h3 id="tabpickup-steps-view0-header" class="card-header"></h3> + <section> + <p> + <span id="error-state-description"></span> + <a id="error-state-link" target="_blank" hidden></a> + </p> + <button id="error-state-button" class="primary"></button> + </section> + </div> + <div name="sync-setup-view1" id="tabpickup-steps-view1" class="card card-no-hover zap-card setup-step" aria-labelledby="tabpickup-steps-view1-header"> + <h2 id="tabpickup-steps-view1-header" data-l10n-id="firefoxview-tabpickup-step-signin-header" class="card-header"></h2> + <section class="card-body"> + <p class="step-content" data-l10n-id="firefoxview-tabpickup-step-signin-description"></p> + <button id="firefoxview-tabpickup-step-signin-primarybutton" class="primary" data-action="view1-primary-action" data-l10n-id="firefoxview-tabpickup-step-signin-primarybutton"></button> + </section> + <footer> + <progress id="tabpickup-steps-view1-progress" class="step-progress" max="100" value="11"></progress> + <label + for="tabpickup-steps-view1-progress" + data-l10n-id="firefoxview-tabpickup-progress-label" + data-l10n-args='{"percentValue":"11"}'></label> + </footer> + </div> + <div name="sync-setup-view2" id="tabpickup-steps-view2" class="card card-no-hover zap-card setup-step" aria-labelledby="tabpickup-steps-view2-header"> + <h2 id="tabpickup-steps-view2-header" data-l10n-id="firefoxview-tabpickup-adddevice-header" class="card-header"></h2> + <section class="card-body"> + <p class="step-content"> + <span data-l10n-id="firefoxview-tabpickup-adddevice-description"></span> + <br/> + <a target="_blank" data-support-url="tab-pickup-firefox-view" data-l10n-id="firefoxview-tabpickup-adddevice-learn-how"></a> + </p> + <button class="primary" data-action="view2-primary-action" data-l10n-id="firefoxview-tabpickup-adddevice-primarybutton"></button> + </section> + <footer> + <progress id="tabpickup-steps-view2-progress" class="step-progress" max="100" value="33"></progress> + <label + for="tabpickup-steps-view2-progress" + data-l10n-id="firefoxview-tabpickup-progress-label" + data-l10n-args='{"percentValue":"33"}'></label> + </footer> + </div> + <div name="sync-setup-view3" id="tabpickup-steps-view3" class="card card-no-hover zap-card setup-step" aria-labelledby="tabpickup-steps-view3-header"> + <h2 id="tabpickup-steps-view3-header" data-l10n-id="firefoxview-tabpickup-synctabs-header" class="card-header"></h2> + <section class="card-body"> + <p class="step-content"> + <span data-l10n-id="firefoxview-tabpickup-synctabs-description"></span> + <br/> + <a target="_blank" data-support-url="tab-pickup-firefox-view" data-l10n-id="firefoxview-tabpickup-synctabs-learn-how"></a> + </p> + <button class="primary" data-action="view3-primary-action" data-l10n-id="firefoxview-tabpickup-synctabs-primarybutton"></button> + </section> + <footer> + <progress id="tabpickup-steps-view3-progress" class="step-progress" max="100" value="66"></progress> + <label + for="tabpickup-steps-view3-progress" + data-l10n-id="firefoxview-tabpickup-progress-label" + data-l10n-args='{"percentValue":"66"}'></label> + </footer> + </div> + </named-deck> + + <div id="tabpickup-tabs-container" role="region" aria-labelledby="collapsible-synced-tabs-header" class="synced-tabs-container" hidden> + <tab-pickup-list> + <ol hidden="true" class="synced-tabs-list"></ol> + </tab-pickup-list> + <div hidden id="synced-tabs-placeholder" class="placeholder-content"> + <img id="tab-pickup-empty-image" src="chrome://browser/content/tab-pickup-empty.svg" role="presentation" alt=""/> + <div class="placeholder-text"> + <h4 data-l10n-id="firefoxview-synced-tabs-placeholder-header" class="placeholder-header"></h4> + <p data-l10n-id="firefoxview-synced-tabs-placeholder-body" class="placeholder-body"></p> + </div> + </div> + <div class="loading-content"> + <icon class="icon sync"></icon> + <p data-l10n-id="firefoxview-tabpickup-syncing"></p> + </div> + </div> + + <div class="promo-box message-box zap-card card-no-hover card" hidden> + <div class="card-body"> + <div class="message-content"> + <h2 data-l10n-id="firefoxview-mobile-promo-header" class="message-header"></h2> + <p class="message-description" data-l10n-id="firefoxview-mobile-promo-description"></p> + </div> + <button class="primary" data-action="mobile-promo-primary-action" data-l10n-id="firefoxview-mobile-promo-primarybutton"></button> + </div> + <button data-action="mobile-promo-dismiss" class="close icon-button ghost-button" data-l10n-id="firefoxview-close-button"></button> + </div> + </details> + + <aside id="colorways" is="colorways-card" hidden> + <figure role="presentation"> + <img id="colorways-collection-figure" role="presentation"> + </figure> + <header> + <h1 id="colorways-collection-title"></h1> + <div id="colorways-collection-expiry-date"><span></span></div> + </header> + <div id="colorways-collection-description"></div> + <button class="primary" id="colorways-button" data-l10n-id="firefoxview-try-colorways-button"></button> + </aside> + + <details class="content-container" is="recently-closed-tabs-container" id="recently-closed-tabs-container" open> + <summary id="recently-closed-tabs-header-section" class="page-section-header" data-l10n-id="firefoxview-collapse-button-hide"> + <span class="twisty icon" aria-role="presentation"></span> + <h1 id="recently-closed-tabs-header" data-l10n-id="firefoxview-closed-tabs-title"></h1> + <p class="section-description" data-l10n-id="firefoxview-closed-tabs-description2"></p> + </summary> + <div id="collapsible-tabs-container" id="recently-closed-tabs" role="region" aria-labelledby="recently-closed-tabs-header"> + <recently-closed-tabs-list> + <ol hidden="true" class="closed-tabs-list"></ol> + </recently-closed-tabs-list> + <div hidden="true" id="recently-closed-tabs-placeholder" class="placeholder-content"> + <img id="recently-closed-empty-image" src="chrome://browser/content/recently-closed-empty.svg" role="presentation" alt=""/> + <div class="placeholder-text"> + <h4 data-l10n-id="firefoxview-closed-tabs-placeholder-header" class="placeholder-header"></h4> + <p data-l10n-id="firefoxview-closed-tabs-placeholder-body" class="placeholder-body"></p> + </div> + </div> + </div> + </details> + </main> + </body> +</html> diff --git a/browser/components/firefoxview/firefoxview.mjs b/browser/components/firefoxview/firefoxview.mjs new file mode 100644 index 0000000000..3500e2db59 --- /dev/null +++ b/browser/components/firefoxview/firefoxview.mjs @@ -0,0 +1,69 @@ +/* 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/. */ + +const { FeatureCallout } = ChromeUtils.importESModule( + "resource:///modules/FeatureCallout.sys.mjs" +); + +const MediaQueryDOMSorting = { + init() { + this.recentlyClosedTabs = document.getElementById( + "recently-closed-tabs-container" + ); + this.colorways = document.getElementById("colorways"); + this.mql = window.matchMedia("(max-width: 65rem)"); + this.mql.addEventListener("change", () => this.changeHandler()); + this.changeHandler(); + }, + cleanup() { + this.mql.removeEventListener("change", () => this.changeHandler()); + }, + changeHandler() { + const oldFocus = document.activeElement; + if (this.mql.matches) { + this.recentlyClosedTabs.before(this.colorways); + } else { + this.colorways.before(this.recentlyClosedTabs); + } + if (oldFocus) { + Services.focus.setFocus(oldFocus, Ci.nsIFocusManager.FLAG_NOSCROLL); + } + }, +}; + +const launchFeatureTour = () => { + let callout = new FeatureCallout({ + win: window, + prefName: "browser.firefox-view.feature-tour", + }); + callout.showFeatureCallout(); +}; + +window.addEventListener("DOMContentLoaded", async () => { + Services.telemetry.setEventRecordingEnabled("firefoxview", true); + Services.telemetry.recordEvent("firefoxview", "entered", "firefoxview", null); + document.getElementById("recently-closed-tabs-container").onLoad(); + MediaQueryDOMSorting.init(); + // If Firefox View was reloaded by the user, force syncing of tabs + // to get the most up to date synced tabs. + if ( + performance + .getEntriesByType("navigation") + .map(nav => nav.type) + .includes("reload") + ) { + await document.getElementById("tab-pickup-container").onReload(); + } + launchFeatureTour(); +}); + +window.addEventListener("unload", () => { + const tabPickupList = document.querySelector("tab-pickup-list"); + if (tabPickupList) { + tabPickupList.cleanup(); + } + document.getElementById("tab-pickup-container").cleanup(); + document.getElementById("recently-closed-tabs-container").cleanup(); + MediaQueryDOMSorting.cleanup(); +}); diff --git a/browser/components/firefoxview/helpers.mjs b/browser/components/firefoxview/helpers.mjs new file mode 100644 index 0000000000..b4df480f73 --- /dev/null +++ b/browser/components/firefoxview/helpers.mjs @@ -0,0 +1,103 @@ +/* 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/. */ + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", + PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "relativeTimeFormat", () => { + return new Services.intl.RelativeTimeFormat(undefined, { style: "narrow" }); +}); + +// Cutoff of 1.5 minutes + 1 second to determine what text string to display +export const NOW_THRESHOLD_MS = 91000; + +export function formatURIForDisplay(uriString) { + return lazy.BrowserUtils.formatURIStringForDisplay(uriString); +} + +export function convertTimestamp( + timestamp, + fluentStrings, + _nowThresholdMs = NOW_THRESHOLD_MS +) { + if (!timestamp) { + // It's marginally better to show nothing instead of "53 years ago" + return ""; + } + const elapsed = Date.now() - timestamp; + let formattedTime; + if (elapsed <= _nowThresholdMs) { + // Use a different string for very recent timestamps + formattedTime = fluentStrings.formatValueSync( + "firefoxview-just-now-timestamp" + ); + } else { + formattedTime = lazy.relativeTimeFormat.formatBestUnit(new Date(timestamp)); + } + return formattedTime; +} + +export function createFaviconElement(image, targetURI = "") { + const imageUrl = image + ? lazy.PlacesUIUtils.getImageURL(image) + : `page-icon:${targetURI}`; + let favicon = document.createElement("div"); + + favicon.style.backgroundImage = `url('${imageUrl}')`; + favicon.classList.add("favicon"); + return favicon; +} + +export function onToggleContainer(detailsContainer) { + // Ignore early `toggle` events, which may either be fired because the + // UI sections update visibility on component connected (based on persisted + // UI state), or because <details> elements fire `toggle` events when added + // to the DOM with the "open" attribute set. In either case, we don't want + // to record telemetry as these events aren't the result of user action. + if (detailsContainer.ownerDocument.readyState != "complete") { + return; + } + + const isOpen = detailsContainer.open; + const isTabPickup = detailsContainer.id === "tab-pickup-container"; + + const newFluentString = isOpen + ? "firefoxview-collapse-button-hide" + : "firefoxview-collapse-button-show"; + + detailsContainer + .querySelector(".twisty") + .setAttribute("data-l10n-id", newFluentString); + + if (isTabPickup) { + Services.telemetry.recordEvent( + "firefoxview", + "tab_pickup_open", + "tabs", + isOpen.toString() + ); + Services.prefs.setBoolPref( + "browser.tabs.firefox-view.ui-state.tab-pickup.open", + isOpen + ); + } else { + Services.telemetry.recordEvent( + "firefoxview", + "closed_tabs_open", + "tabs", + isOpen.toString() + ); + Services.prefs.setBoolPref( + "browser.tabs.firefox-view.ui-state.recently-closed-tabs.open", + isOpen + ); + } +} diff --git a/browser/components/firefoxview/jar.mn b/browser/components/firefoxview/jar.mn new file mode 100644 index 0000000000..72d1fe8d87 --- /dev/null +++ b/browser/components/firefoxview/jar.mn @@ -0,0 +1,21 @@ +# 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/. + +browser.jar: + content/browser/firefoxview.html + content/browser/firefoxview.mjs + content/browser/firefoxview.css + content/browser/helpers.mjs + content/browser/tab-pickup-container.mjs + content/browser/tab-pickup-list.mjs + content/browser/recently-closed-tabs.mjs + content/browser/colorways-card.mjs + content/browser/callout-tab-pickup.svg (content/callout-tab-pickup.svg) + content/browser/callout-colorways.svg (content/callout-colorways.svg) + content/browser/callout-tab-pickup-dark.svg (content/callout-tab-pickup-dark.svg) + content/browser/callout-colorways-dark.svg (content/callout-colorways-dark.svg) + content/browser/cfr-lightning.svg (content/cfr-lightning.svg) + content/browser/cfr-lightning-dark.svg (content/cfr-lightning-dark.svg) + content/browser/recently-closed-empty.svg (content/recently-closed-empty.svg) + content/browser/tab-pickup-empty.svg (content/tab-pickup-empty.svg) diff --git a/browser/components/firefoxview/moz.build b/browser/components/firefoxview/moz.build new file mode 100644 index 0000000000..381992ef02 --- /dev/null +++ b/browser/components/firefoxview/moz.build @@ -0,0 +1,16 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +JAR_MANIFESTS += ["jar.mn"] + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Firefox View") + +EXTRA_JS_MODULES += [ + "*.sys.mjs", +] + +BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.ini"] diff --git a/browser/components/firefoxview/recently-closed-tabs.mjs b/browser/components/firefoxview/recently-closed-tabs.mjs new file mode 100644 index 0000000000..e3a207fc5d --- /dev/null +++ b/browser/components/firefoxview/recently-closed-tabs.mjs @@ -0,0 +1,488 @@ +/* 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/. */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", +}); + +import { + formatURIForDisplay, + convertTimestamp, + createFaviconElement, + onToggleContainer, + NOW_THRESHOLD_MS, +} from "./helpers.mjs"; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const SS_NOTIFY_CLOSED_OBJECTS_CHANGED = "sessionstore-closed-objects-changed"; +const SS_NOTIFY_BROWSER_SHUTDOWN_FLUSH = "sessionstore-browser-shutdown-flush"; +const UI_OPEN_STATE = + "browser.tabs.firefox-view.ui-state.recently-closed-tabs.open"; + +function getWindow() { + return window.browsingContext.embedderWindowGlobal.browsingContext.window; +} + +class RecentlyClosedTabsList extends HTMLElement { + constructor() { + super(); + this.maxTabsLength = 25; + this.closedTabsData = new Map(); + + // The recency timestamp update period is stored in a pref to allow tests to easily change it + XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "timeMsPref", + "browser.tabs.firefox-view.updateTimeMs", + NOW_THRESHOLD_MS, + () => this.updateTime() + ); + } + + get tabsList() { + return this.querySelector("ol"); + } + + get fluentStrings() { + if (!this._fluentStrings) { + this._fluentStrings = new Localization(["browser/firefoxView.ftl"], true); + } + return this._fluentStrings; + } + + get timeElements() { + return this.querySelectorAll("span.closed-tab-li-time"); + } + + connectedCallback() { + this.addEventListener("click", this); + this.addEventListener("keydown", this); + this.intervalID = setInterval(() => this.updateTime(), lazy.timeMsPref); + } + + disconnectedCallback() { + clearInterval(this.intervalID); + } + + handleEvent(event) { + if ( + (event.type == "click" && !event.altKey) || + (event.type == "keydown" && event.keyCode == KeyEvent.DOM_VK_RETURN) || + (event.type == "keydown" && event.keyCode == KeyEvent.DOM_VK_SPACE) + ) { + if (!event.target.classList.contains("closed-tab-li-dismiss")) { + this.openTabAndUpdate(event); + } else { + this.dismissTabAndUpdate(event); + } + } + } + + updateTime() { + for (let timeEl of this.timeElements) { + timeEl.textContent = convertTimestamp( + parseInt(timeEl.getAttribute("data-timestamp")), + this.fluentStrings, + lazy.timeMsPref + ); + } + } + + getTabStateValue(tab, key) { + let value = ""; + const tabEntries = tab.state.entries; + const activeIndex = tab.state.index - 1; + + if (activeIndex >= 0 && tabEntries[activeIndex]) { + value = tabEntries[activeIndex][key]; + } + + return value; + } + + focusFirstItemOrHeader(dismissedIndex) { + // When a tab is removed from the list, the focus should + // remain on the list or the list header. This prevents context + // switching when navigating back to Firefox View. + let recentlyClosedList = [...this.tabsList.children]; + if (recentlyClosedList.length) { + recentlyClosedList.forEach(element => + element.setAttribute("tabindex", "-1") + ); + let mainContent; + if (dismissedIndex) { + // Select the item above the one that was just dismissed + mainContent = recentlyClosedList[dismissedIndex - 1].querySelector( + ".closed-tab-li-main" + ); + } else { + mainContent = recentlyClosedList[0].querySelector( + ".closed-tab-li-main" + ); + } + mainContent.setAttribute("tabindex", "0"); + mainContent.focus(); + } else { + document.getElementById("recently-closed-tabs-header-section").focus(); + } + } + + openTabAndUpdate(event) { + event.preventDefault(); + const item = event.target.closest(".closed-tab-li"); + // only used for telemetry + const position = [...this.tabsList.children].indexOf(item) + 1; + const closedId = item.dataset.tabid; + + lazy.SessionStore.undoCloseById(closedId); + this.tabsList.removeChild(item); + + this.focusFirstItemOrHeader(); + + // record telemetry + let tabClosedAt = parseInt( + item.querySelector(".closed-tab-li-time").getAttribute("data-timestamp") + ); + + let now = Date.now(); + let deltaSeconds = (now - tabClosedAt) / 1000; + Services.telemetry.recordEvent( + "firefoxview", + "recently_closed", + "tabs", + null, + { + position: position.toString(), + delta: deltaSeconds.toString(), + } + ); + } + + dismissTabAndUpdate(event) { + event.preventDefault(); + const item = event.target.closest(".closed-tab-li"); + let recentlyClosedList = lazy.SessionStore.getClosedTabData(getWindow()); + let closedTabIndex = recentlyClosedList.findIndex(closedTab => { + return closedTab.closedId === parseInt(item.dataset.tabid, 10); + }); + if (closedTabIndex < 0) { + // Tab not found in recently closed list + return; + } + this.tabsList.removeChild(item); + lazy.SessionStore.forgetClosedTab(getWindow(), closedTabIndex); + + this.focusFirstItemOrHeader(closedTabIndex); + + // record telemetry + let tabClosedAt = parseInt( + item.querySelector(".closed-tab-li-time").dataset.timestamp + ); + + let now = Date.now(); + let deltaSeconds = (now - tabClosedAt) / 1000; + Services.telemetry.recordEvent( + "firefoxview", + "dismiss_closed_tab", + "tabs", + null, + { + delta: deltaSeconds.toString(), + } + ); + } + + updateTabsList() { + let newClosedTabs = lazy.SessionStore.getClosedTabData(getWindow()); + newClosedTabs = newClosedTabs.slice(0, this.maxTabsLength); + + if (this.closedTabsData.size && !newClosedTabs.length) { + // if a user purges history, clear the list + while (this.tabsList.lastElementChild) { + this.tabsList.lastElementChild.remove(); + } + document + .getElementById("recently-closed-tabs-container") + .togglePlaceholderVisibility(true); + this.tabsList.hidden = true; + this.closedTabsData = new Map(); + return; + } + + // First purge obsolete items out of the map so we don't leak them forever: + for (let id of this.closedTabsData.keys()) { + if (!newClosedTabs.some(t => t.closedId == id)) { + this.closedTabsData.delete(id); + } + } + + // Then work out which of the new closed tabs are additions and which update + // existing items: + let tabsToAdd = []; + let tabsToUpdate = []; + for (let newTab of newClosedTabs) { + let oldTab = this.closedTabsData.get(newTab.closedId); + this.closedTabsData.set(newTab.closedId, newTab); + if (!oldTab) { + tabsToAdd.push(newTab); + } else if ( + this.getTabStateValue(oldTab, "url") != + this.getTabStateValue(newTab, "url") + ) { + tabsToUpdate.push(newTab); + } + } + + // Remove existing tabs from tabsList if not in latest closedTabsData + // which is necessary when using "Reopen Closed Tab" from the toolbar + // or when selecting "Forget this site" in History + [...this.tabsList.children].forEach(existingTab => { + if (!this.closedTabsData.get(parseInt(existingTab.dataset.tabid, 10))) { + this.tabsList.removeChild(existingTab); + } + }); + + // If there's nothing to add/update, return. + if (!tabsToAdd.length && !tabsToUpdate.length) { + return; + } + + // Add new tabs. + for (let tab of tabsToAdd.reverse()) { + if (this.tabsList.children.length == this.maxTabsLength) { + this.tabsList.lastChild.remove(); + } + let li = this.generateListItem(tab); + let mainContent = li.querySelector(".closed-tab-li-main"); + // Only the first item in the list should be focusable + if (!this.tabsList.children.length) { + mainContent.setAttribute("tabindex", "0"); + } else if (this.tabsList.children.length) { + mainContent.setAttribute("tabindex", "0"); + this.tabsList.children[0].setAttribute("tabindex", "-1"); + } + this.tabsList.prepend(li); + } + + // Update any recently closed tabs that now have different URLs: + for (let tab of tabsToUpdate) { + let tabElement = this.querySelector( + `.closed-tab-li[data-tabid="${tab.closedId}"]` + ); + let url = this.getTabStateValue(tab, "url"); + this.updateURLForListItem(tabElement, url); + } + + // Now unhide the list if necessary: + if (this.tabsList.hidden) { + this.tabsList.hidden = false; + document + .getElementById("recently-closed-tabs-container") + .togglePlaceholderVisibility(false); + } + } + + generateListItem(tab) { + const li = document.createElement("li"); + li.classList.add("closed-tab-li"); + li.dataset.tabid = tab.closedId; + + const title = document.createElement("span"); + title.textContent = `${tab.title}`; + title.classList.add("closed-tab-li-title"); + + const targetURI = this.getTabStateValue(tab, "url"); + const image = tab.image; + const favicon = createFaviconElement(image, targetURI); + + const urlElement = document.createElement("span"); + urlElement.classList.add("closed-tab-li-url"); + + const time = document.createElement("span"); + const convertedTime = convertTimestamp(tab.closedAt, this.fluentStrings); + time.textContent = convertedTime; + time.setAttribute("data-timestamp", tab.closedAt); + time.classList.add("closed-tab-li-time"); + + const mainContent = document.createElement("span"); + mainContent.classList.add("closed-tab-li-main"); + mainContent.setAttribute("role", "link"); + mainContent.setAttribute("tabindex", 0); + mainContent.append(favicon, title, urlElement, time); + + const dismissButton = document.createElement("button"); + let tabTitle = tab.title ?? ""; + document.l10n.setAttributes( + dismissButton, + "firefoxview-closed-tabs-dismiss-tab", + { + tabTitle, + } + ); + dismissButton.classList.add("closed-tab-li-dismiss"); + + li.append(mainContent, dismissButton); + this.updateURLForListItem(li, targetURI); + return li; + } + + // Update the URL for a new or previously-populated list item. + // This is needed because when tabs get closed we don't necessarily + // have all the requisite information for them immediately. + updateURLForListItem(li, targetURI) { + li.dataset.targetURI = targetURI; + let urlElement = li.querySelector(".closed-tab-li-url"); + document.l10n.setAttributes( + urlElement, + "firefoxview-tabs-list-tab-button", + { + targetURI, + } + ); + if (targetURI) { + urlElement.textContent = formatURIForDisplay(targetURI); + urlElement.title = targetURI; + } else { + urlElement.textContent = urlElement.title = ""; + } + } +} +customElements.define("recently-closed-tabs-list", RecentlyClosedTabsList); + +class RecentlyClosedTabsContainer extends HTMLDetailsElement { + constructor() { + super(); + this.observerAdded = false; + this.boundObserve = (...args) => this.observe(...args); + } + + connectedCallback() { + this.noTabsElement = this.querySelector( + "#recently-closed-tabs-placeholder" + ); + this.list = this.querySelector("recently-closed-tabs-list"); + this.collapsibleContainer = this.querySelector( + "#collapsible-tabs-container" + ); + this.addEventListener("toggle", this); + getWindow().gBrowser.tabContainer.addEventListener("TabSelect", this); + this.open = Services.prefs.getBoolPref(UI_OPEN_STATE, true); + } + + cleanup() { + getWindow().gBrowser.tabContainer.removeEventListener("TabSelect", this); + this.removeObserversIfNeeded(); + } + + addObserversIfNeeded() { + if (!this.observerAdded) { + Services.obs.addObserver( + this.boundObserve, + SS_NOTIFY_CLOSED_OBJECTS_CHANGED + ); + Services.obs.addObserver( + this.boundObserve, + SS_NOTIFY_BROWSER_SHUTDOWN_FLUSH + ); + this.observerAdded = true; + } + } + + removeObserversIfNeeded() { + if (this.observerAdded) { + Services.obs.removeObserver( + this.boundObserve, + SS_NOTIFY_CLOSED_OBJECTS_CHANGED + ); + Services.obs.removeObserver( + this.boundObserve, + SS_NOTIFY_BROWSER_SHUTDOWN_FLUSH + ); + this.observerAdded = false; + } + } + + // we observe when a tab closes but since this notification fires more frequently and on + // all windows, we remove the observer when another tab is selected; we check for changes + // to the session store once the user return to this tab. + handleObservers(contentDocument) { + if (contentDocument?.URL == "about:firefoxview") { + this.addObserversIfNeeded(); + this.list.updateTabsList(); + this.maybeUpdateFocus(); + } else { + this.removeObserversIfNeeded(); + } + } + + observe(subject, topic, data) { + if ( + topic == SS_NOTIFY_CLOSED_OBJECTS_CHANGED || + (topic == SS_NOTIFY_BROWSER_SHUTDOWN_FLUSH && + subject.ownerGlobal == getWindow()) + ) { + this.list.updateTabsList(); + } + } + + onLoad() { + if (this.getClosedTabCount() == 0) { + this.togglePlaceholderVisibility(true); + } else { + this.list.updateTabsList(); + } + this.addObserversIfNeeded(); + } + + handleEvent(event) { + if (event.type == "toggle") { + onToggleContainer(this); + } else if (event.type == "TabSelect") { + this.handleObservers(event.target.linkedBrowser.contentDocument); + } + } + + /** + * Manages focus when returning to the Firefox View tab + * + * @memberof RecentlyClosedTabsContainer + */ + maybeUpdateFocus() { + // Check if focus is in the container element + if (this.contains(document.activeElement)) { + let listItems = this.list.querySelectorAll("li"); + // More tabs may have been added to the list, so we'll refocus + // the first item in the list. + if (listItems.length) { + listItems[0].querySelector(".closed-tab-li-main").focus(); + } else { + this.querySelector("summary").focus(); + } + } + } + + togglePlaceholderVisibility(visible) { + this.noTabsElement.toggleAttribute("hidden", !visible); + this.collapsibleContainer.classList.toggle("empty-container", visible); + } + + getClosedTabCount = () => { + try { + return lazy.SessionStore.getClosedTabCount(getWindow()); + } catch (ex) { + return 0; + } + }; +} +customElements.define( + "recently-closed-tabs-container", + RecentlyClosedTabsContainer, + { + extends: "details", + } +); diff --git a/browser/components/firefoxview/tab-pickup-container.mjs b/browser/components/firefoxview/tab-pickup-container.mjs new file mode 100644 index 0000000000..f554298a2a --- /dev/null +++ b/browser/components/firefoxview/tab-pickup-container.mjs @@ -0,0 +1,323 @@ +/* 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/remote-page */ + +import { onToggleContainer } from "./helpers.mjs"; + +const { TabsSetupFlowManager } = ChromeUtils.importESModule( + "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs" +); + +const TOPIC_SETUPSTATE_CHANGED = "firefox-view.setupstate.changed"; +const UI_OPEN_STATE = "browser.tabs.firefox-view.ui-state.tab-pickup.open"; + +class TabPickupContainer extends HTMLDetailsElement { + constructor() { + super(); + this.boundObserve = (...args) => this.observe(...args); + this._currentSetupStateIndex = -1; + this.errorState = null; + this.tabListAdded = null; + } + get setupContainerElem() { + return this.querySelector(".sync-setup-container"); + } + + get tabsContainerElem() { + return this.querySelector(".synced-tabs-container"); + } + + get tabPickupListElem() { + return this.querySelector(".synced-tabs-container tab-pickup-list"); + } + + getWindow() { + return this.ownerGlobal.browsingContext.embedderWindowGlobal.browsingContext + .window; + } + + connectedCallback() { + this.addEventListener("click", this); + this.addEventListener("toggle", this); + this.addEventListener("visibilitychange", this); + Services.obs.addObserver(this.boundObserve, TOPIC_SETUPSTATE_CHANGED); + + for (let elem of this.querySelectorAll("a[data-support-url]")) { + elem.href = + Services.urlFormatter.formatURLPref("app.support.baseURL") + + elem.dataset.supportUrl; + } + + // we wait until the list shows up before trying to populate it, + // when its safe to assume the custom-element's methods will be available + this.tabListAdded = this.promiseChildAdded(); + this.update(); + } + + promiseChildAdded() { + return new Promise(resolve => { + if (typeof this.tabPickupListElem?.getSyncedTabData == "function") { + resolve(); + return; + } + this.addEventListener( + "list-ready", + event => { + resolve(); + }, + { once: true } + ); + }); + } + + cleanup() { + Services.obs.removeObserver(this.boundObserve, TOPIC_SETUPSTATE_CHANGED); + } + + disconnectedCallback() { + this.cleanup(); + } + + handleEvent(event) { + if (event.type == "toggle") { + onToggleContainer(this); + return; + } + if (event.type == "click" && event.target.dataset.action) { + switch (event.target.dataset.action) { + case "view0-sync-error-action": + case "view0-network-offline-action": + case "view0-password-locked-action": { + TabsSetupFlowManager.tryToClearError(); + break; + } + case "view0-signed-out-action": + case "view1-primary-action": { + TabsSetupFlowManager.openFxASignup(event.target.ownerGlobal); + break; + } + case "view2-primary-action": + case "mobile-promo-primary-action": { + TabsSetupFlowManager.openFxAPairDevice(event.target.ownerGlobal); + break; + } + case "view3-primary-action": { + TabsSetupFlowManager.syncOpenTabs(event.target); + break; + } + case "mobile-promo-dismiss": { + TabsSetupFlowManager.dismissMobilePromo(event.target); + break; + } + case "mobile-confirmation-dismiss": { + TabsSetupFlowManager.dismissMobileConfirmation(event.target); + break; + } + case "view0-sync-disconnected-action": { + const window = event.target.ownerGlobal; + const { + switchToTabHavingURI, + } = window.docShell.chromeEventHandler.ownerGlobal; + switchToTabHavingURI( + "about:preferences?action=choose-what-to-sync#sync", + true, + {} + ); + break; + } + } + } + // Returning to fxview seems like a likely time for a device check + if ( + event.type == "visibilitychange" && + document.visibilityState === "visible" + ) { + this.update(); + } + } + + async observe(subject, topic, errorState) { + if (topic == TOPIC_SETUPSTATE_CHANGED) { + this.update({ errorState }); + } + } + + get mobilePromoElem() { + return this.querySelector(".promo-box"); + } + get mobileSuccessElem() { + return this.querySelector(".confirmation-message-box"); + } + + update({ + stateIndex = TabsSetupFlowManager.uiStateIndex, + showMobilePromo = TabsSetupFlowManager.shouldShowMobilePromo, + showMobilePairSuccess = TabsSetupFlowManager.shouldShowMobileConnectedSuccess, + errorState = TabsSetupFlowManager.getErrorType(), + waitingForTabs = TabsSetupFlowManager.waitingForTabs, + } = {}) { + let needsRender = false; + if (waitingForTabs !== this._waitingForTabs) { + this._waitingForTabs = waitingForTabs; + needsRender = true; + } + + if (showMobilePromo !== this._showMobilePromo) { + this._showMobilePromo = showMobilePromo; + needsRender = true; + } + if (showMobilePairSuccess !== this._showMobilePairSuccess) { + this._showMobilePairSuccess = showMobilePairSuccess; + needsRender = true; + } + if (stateIndex == 4 && this._currentSetupStateIndex !== stateIndex) { + // trigger an initial request for the synced tabs list + this.tabListAdded.then(() => { + this.tabPickupListElem.getSyncedTabData(); + }); + } + if (stateIndex !== this._currentSetupStateIndex || stateIndex == 0) { + this._currentSetupStateIndex = stateIndex; + needsRender = true; + this.errorState = errorState; + } + needsRender && this.render(); + } + + generateErrorMessage() { + // We map the error state strings to Fluent string IDs so that it's easier + // to change strings in the future without having to update all of the + // error state strings. + const errorStateStringMappings = { + "sync-error": { + header: "firefoxview-tabpickup-sync-error-header", + description: "firefoxview-tabpickup-generic-sync-error-description", + buttonLabel: "firefoxview-tabpickup-sync-error-primarybutton", + }, + + "fxa-admin-disabled": { + header: "firefoxview-tabpickup-fxa-admin-disabled-header", + description: "firefoxview-tabpickup-fxa-admin-disabled-description", + // The button is hidden for this errorState, so we don't include the + // buttonLabel property. + }, + + "network-offline": { + header: "firefoxview-tabpickup-network-offline-header", + description: "firefoxview-tabpickup-network-offline-description", + buttonLabel: "firefoxview-tabpickup-network-offline-primarybutton", + }, + + "sync-disconnected": { + header: "firefoxview-tabpickup-sync-disconnected-header", + description: "firefoxview-tabpickup-sync-disconnected-description", + buttonLabel: "firefoxview-tabpickup-sync-disconnected-primarybutton", + }, + + "password-locked": { + header: "firefoxview-tabpickup-password-locked-header", + description: "firefoxview-tabpickup-password-locked-description", + buttonLabel: "firefoxview-tabpickup-password-locked-primarybutton", + link: { + label: "firefoxview-tabpickup-password-locked-link", + href: + Services.urlFormatter.formatURLPref("app.support.baseURL") + + "primary-password-stored-logins", + }, + }, + "signed-out": { + header: "firefoxview-tabpickup-signed-out-header", + description: "firefoxview-tabpickup-signed-out-description", + buttonLabel: "firefoxview-tabpickup-signed-out-primarybutton", + }, + }; + + const errorStateHeader = this.querySelector( + "#tabpickup-steps-view0-header" + ); + const errorStateDescription = this.querySelector( + "#error-state-description" + ); + const errorStateButton = this.querySelector("#error-state-button"); + const errorStateLink = this.querySelector("#error-state-link"); + const errorStateProperties = errorStateStringMappings[this.errorState]; + + document.l10n.setAttributes(errorStateHeader, errorStateProperties.header); + document.l10n.setAttributes( + errorStateDescription, + errorStateProperties.description + ); + + errorStateButton.hidden = this.errorState == "fxa-admin-disabled"; + + if (this.errorState != "fxa-admin-disabled") { + document.l10n.setAttributes( + errorStateButton, + errorStateProperties.buttonLabel + ); + errorStateButton.setAttribute( + "data-action", + `view0-${this.errorState}-action` + ); + } + + if (errorStateProperties.link) { + document.l10n.setAttributes( + errorStateLink, + errorStateProperties.link.label + ); + errorStateLink.href = errorStateProperties.link.href; + errorStateLink.hidden = false; + } else { + errorStateLink.hidden = true; + } + } + + render() { + if (!this.isConnected) { + return; + } + + let setupElem = this.setupContainerElem; + let tabsElem = this.tabsContainerElem; + let mobilePromoElem = this.mobilePromoElem; + let mobileSuccessElem = this.mobileSuccessElem; + + const stateIndex = this._currentSetupStateIndex; + const isLoading = this._waitingForTabs; + + mobilePromoElem.hidden = !this._showMobilePromo; + mobileSuccessElem.hidden = !this._showMobilePairSuccess; + + this.open = + !TabsSetupFlowManager.isTabSyncSetupComplete || + Services.prefs.getBoolPref(UI_OPEN_STATE, true); + + // show/hide either the setup or tab list containers, creating each as necessary + if (stateIndex < 4) { + tabsElem.hidden = true; + setupElem.hidden = false; + setupElem.selectedViewName = `sync-setup-view${stateIndex}`; + + if (stateIndex == 0 && this.errorState) { + this.generateErrorMessage(); + } + return; + } + + setupElem.hidden = true; + tabsElem.hidden = false; + tabsElem.classList.toggle("loading", isLoading); + } + + async onReload() { + await TabsSetupFlowManager.syncOnPageReload(); + } +} +customElements.define("tab-pickup-container", TabPickupContainer, { + extends: "details", +}); + +export { TabPickupContainer }; diff --git a/browser/components/firefoxview/tab-pickup-list.mjs b/browser/components/firefoxview/tab-pickup-list.mjs new file mode 100644 index 0000000000..6cc5a61ff6 --- /dev/null +++ b/browser/components/firefoxview/tab-pickup-list.mjs @@ -0,0 +1,348 @@ +/* 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/. */ + +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "SyncedTabs", + "resource://services-sync/SyncedTabs.jsm" +); + +import { + formatURIForDisplay, + convertTimestamp, + createFaviconElement, + NOW_THRESHOLD_MS, +} from "./helpers.mjs"; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const SYNCED_TABS_CHANGED = "services.sync.tabs.changed"; + +class TabPickupList extends HTMLElement { + constructor() { + super(); + this.maxTabsLength = 3; + this.boundObserve = (...args) => { + this.getSyncedTabData(...args); + }; + + // The recency timestamp update period is stored in a pref to allow tests to easily change it + XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "timeMsPref", + "browser.tabs.firefox-view.updateTimeMs", + NOW_THRESHOLD_MS, + () => this.updateTime() + ); + } + + get tabsList() { + return this.querySelector("ol"); + } + + get fluentStrings() { + if (!this._fluentStrings) { + this._fluentStrings = new Localization(["browser/firefoxView.ftl"], true); + } + return this._fluentStrings; + } + + get timeElements() { + return this.querySelectorAll("span.synced-tab-li-time"); + } + + connectedCallback() { + this.placeholderContainer = document.getElementById( + "synced-tabs-placeholder" + ); + this.tabPickupContainer = document.getElementById( + "tabpickup-tabs-container" + ); + + this.addEventListener("click", this); + Services.obs.addObserver(this.boundObserve, SYNCED_TABS_CHANGED); + + // inform ancestor elements our getSyncedTabData method is available to fetch data + this.dispatchEvent(new CustomEvent("list-ready", { bubbles: true })); + } + + handleEvent(event) { + if ( + event.type == "click" || + (event.type == "keydown" && event.keyCode == KeyEvent.DOM_VK_RETURN) + ) { + const item = event.target.closest(".synced-tab-li"); + let index = [...this.tabsList.children].indexOf(item); + let deviceType = item.dataset.deviceType; + Services.telemetry.recordEvent( + "firefoxview", + "tab_pickup", + "tabs", + null, + { + position: (++index).toString(), + deviceType, + } + ); + } + if (event.type == "keydown") { + switch (event.key) { + case "ArrowRight": { + event.preventDefault(); + this.moveFocusToSecondElement(); + break; + } + case "ArrowLeft": { + event.preventDefault(); + this.moveFocusToFirstElement(); + break; + } + case "ArrowDown": { + event.preventDefault(); + this.moveFocusToNextElement(); + break; + } + case "ArrowUp": { + event.preventDefault(); + this.moveFocusToPreviousElement(); + break; + } + case "Tab": { + this.resetFocus(event); + } + } + } + } + + /** + * Handles removing and setting tabindex on elements + * while moving focus to the next element + * + * @param {HTMLElement} currentElement currently focused element + * @param {HTMLElement} nextElement element that should receive focus next + * @memberof TabPickupList + * @private + */ + #manageTabIndexAndFocus(currentElement, nextElement) { + currentElement.setAttribute("tabindex", "-1"); + nextElement.removeAttribute("tabindex"); + nextElement.focus(); + } + + moveFocusToFirstElement() { + let selectableElements = Array.from(this.tabsList.querySelectorAll("a")); + let firstElement = selectableElements[0]; + let selectedElement = this.tabsList.querySelector("a:not([tabindex]"); + this.#manageTabIndexAndFocus(selectedElement, firstElement); + } + + moveFocusToSecondElement() { + let selectableElements = Array.from(this.tabsList.querySelectorAll("a")); + let secondElement = selectableElements[1]; + if (secondElement) { + let selectedElement = this.tabsList.querySelector("a:not([tabindex]"); + this.#manageTabIndexAndFocus(selectedElement, secondElement); + } + } + + moveFocusToNextElement() { + let selectableElements = Array.from(this.tabsList.querySelectorAll("a")); + let selectedElement = this.tabsList.querySelector("a:not([tabindex]"); + let nextElement = + selectableElements.findIndex(elem => elem == selectedElement) + 1; + if (nextElement < selectableElements.length) { + this.#manageTabIndexAndFocus( + selectedElement, + selectableElements[nextElement] + ); + } + } + moveFocusToPreviousElement() { + let selectableElements = Array.from(this.tabsList.querySelectorAll("a")); + let selectedElement = this.tabsList.querySelector("a:not([tabindex]"); + let previousElement = + selectableElements.findIndex(elem => elem == selectedElement) - 1; + if (previousElement >= 0) { + this.#manageTabIndexAndFocus( + selectedElement, + selectableElements[previousElement] + ); + } + } + resetFocus(e) { + let selectableElements = Array.from(this.tabsList.querySelectorAll("a")); + let selectedElement = this.tabsList.querySelector("a:not([tabindex]"); + selectedElement.setAttribute("tabindex", "-1"); + selectableElements[0].removeAttribute("tabindex"); + if (e.shiftKey) { + e.preventDefault(); + document + .getElementById("tab-pickup-container") + .querySelector("summary") + .focus(); + } + } + + cleanup() { + Services.obs.removeObserver(this.boundObserve, SYNCED_TABS_CHANGED); + clearInterval(this.intervalID); + } + + updateTime() { + for (let timeEl of this.timeElements) { + timeEl.textContent = convertTimestamp( + parseInt(timeEl.getAttribute("data-timestamp")), + this.fluentStrings, + lazy.timeMsPref + ); + } + } + + togglePlaceholderVisibility(visible) { + this.placeholderContainer.toggleAttribute("hidden", !visible); + this.placeholderContainer.classList.toggle("empty-container", visible); + } + + async getSyncedTabData() { + let tabs = await lazy.SyncedTabs.getRecentTabs(50); + + this.updateTabsList(tabs); + } + + updateTabsList(syncedTabs) { + // don't do anything while the loading state is active + + while (this.tabsList.firstChild) { + this.tabsList.firstChild.remove(); + } + + if (!syncedTabs.length) { + this.sendTabTelemetry(0); + this.togglePlaceholderVisibility(true); + this.tabsList.hidden = true; + return; + } + + for (let i = 0; i < this.maxTabsLength; i++) { + let li = null; + if (!syncedTabs[i]) { + li = this.generatePlaceholder(); + } else { + li = this.generateListItem(syncedTabs[i], i); + } + this.tabsList.append(li); + } + + if (this.tabsList.hidden) { + this.tabsList.hidden = false; + this.togglePlaceholderVisibility(false); + + if (!this.intervalID) { + this.intervalID = setInterval(() => this.updateTime(), lazy.timeMsPref); + } + } + + this.sendTabTelemetry(syncedTabs.length); + } + + generatePlaceholder() { + const li = document.createElement("li"); + li.classList.add("synced-tab-li-placeholder"); + li.setAttribute("role", "presentation"); + + const favicon = document.createElement("span"); + favicon.classList.add("li-placeholder-favicon"); + + const title = document.createElement("span"); + title.classList.add("li-placeholder-title"); + + const domain = document.createElement("span"); + domain.classList.add("li-placeholder-domain"); + + li.append(favicon, title, domain); + return li; + } + + generateListItem(tab, index) { + const li = document.createElement("li"); + li.classList.add("synced-tab-li"); + li.dataset.deviceType = tab.deviceType; + + const targetURI = tab.url; + const a = document.createElement("a"); + a.classList.add("synced-tab-a"); + a.href = targetURI; + a.target = "_blank"; + if (index != 0) { + a.setAttribute("tabindex", "-1"); + } + a.addEventListener("keydown", this); + + const title = document.createElement("span"); + title.textContent = tab.title; + title.classList.add("synced-tab-li-title"); + + const favicon = createFaviconElement(tab.icon, targetURI); + + const lastUsedMs = tab.lastUsed * 1000; + const time = document.createElement("span"); + time.textContent = convertTimestamp(lastUsedMs, this.fluentStrings); + time.classList.add("synced-tab-li-time"); + time.setAttribute("data-timestamp", lastUsedMs); + + const url = document.createElement("span"); + const device = document.createElement("span"); + const deviceIcon = document.createElement("div"); + deviceIcon.classList.add("icon", tab.deviceType); + deviceIcon.setAttribute("role", "presentation"); + + const deviceText = tab.device; + device.textContent = deviceText; + device.prepend(deviceIcon); + device.title = deviceText; + + url.textContent = formatURIForDisplay(tab.url); + url.title = tab.url; + url.classList.add("synced-tab-li-url"); + document.l10n.setAttributes(url, "firefoxview-tabs-list-tab-button", { + targetURI, + }); + device.classList.add("synced-tab-li-device"); + + // the first list item is different from the second and third + if (index == 0) { + const badge = this.createBadge(); + a.append(favicon, badge, title, url, device, time); + } else { + a.append(favicon, title, url, device, time); + } + + li.append(a); + return li; + } + + createBadge() { + const badge = document.createElement("div"); + const dot = document.createElement("span"); + const badgeText = document.createElement("span"); + + badgeText.setAttribute("data-l10n-id", "firefoxview-pickup-tabs-badge"); + badgeText.classList.add("badge-text"); + badge.classList.add("last-active-badge"); + dot.classList.add("dot"); + badge.append(dot, badgeText); + return badge; + } + + sendTabTelemetry(numTabs) { + Services.telemetry.recordEvent("firefoxview", "synced_tabs", "tabs", null, { + count: numTabs.toString(), + }); + } +} + +customElements.define("tab-pickup-list", TabPickupList); diff --git a/browser/components/firefoxview/tests/browser/browser.ini b/browser/components/firefoxview/tests/browser/browser.ini new file mode 100644 index 0000000000..3e0507b747 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser.ini @@ -0,0 +1,33 @@ +[DEFAULT] +support-files = head.js +prefs = + browser.tabs.firefox-view.logLevel=All + +[browser_dragDrop_after_opening_fxViewTab.js] +[browser_entrypoint_management.js] +[browser_firefoxview.js] +[browser_firefoxview_accessibility.js] +[browser_firefoxview_feature_callout_a11y.js] +[browser_firefoxview_tab.js] +[browser_keyboard_focus.js] +[browser_media_query_dom_sorting.js] +[browser_notification_dot.js] +[browser_recently_closed_tabs.js] +[browser_recently_closed_tabs_keyboard.js] +[browser_reload_firefoxview.js] +[browser_setup_errors.js] +[browser_setup_primary_password.js] +[browser_setup_state.js] +[browser_setup_synced_tabs_loading.js] +[browser_sync_admin_disabled.js] +[browser_tab_pickup_list.js] +[browser_colorways_card.js] +[browser_cfr_message.js] +skip-if = true # Bug 1783684 +[browser_feature_callout.js] +[browser_feature_callout_position.js] +[browser_feature_callout_resize.js] +[browser_feature_callout_targeting.js] +[browser_tab_close_last_tab.js] +[browser_tab_on_close_warning.js] +[browser_ui_state.js] diff --git a/browser/components/firefoxview/tests/browser/browser_cfr_message.js b/browser/components/firefoxview/tests/browser/browser_cfr_message.js new file mode 100644 index 0000000000..ee9df2f105 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_cfr_message.js @@ -0,0 +1,67 @@ +const { ASRouter } = ChromeUtils.import( + "resource://activity-stream/lib/ASRouter.jsm" +); +const { ASRouterTriggerListeners } = ChromeUtils.import( + "resource://activity-stream/lib/ASRouterTriggerListeners.jsm" +); + +const { SpecialMessageActions } = ChromeUtils.import( + "resource://messaging-system/lib/SpecialMessageActions.jsm" +); + +add_task(async function cfr_firefoxview_should_show() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.firefox-view.view-count", 0]], + }); + + let cfrSpy = sinon.spy(ASRouter, "routeCFRMessage"); + let specialMessageActionsSpy = sinon.spy( + SpecialMessageActions, + "handleAction" + ); + registerCleanupFunction(() => { + cfrSpy.restore(); + specialMessageActionsSpy.restore(); + ASRouter.resetMessageState(); + ASRouter.unblockMessageById("CFR_FIREFOX_VIEW"); + ASRouterTriggerListeners.get("nthTabClosed").uninit(); + }); + + let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser); + let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser); + let tab3 = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + const showPanel = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown", + target => { + return target; + } + ); + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); + BrowserTestUtils.removeTab(tab3); + + await showPanel; + + Assert.equal(cfrSpy.lastCall.args[0].id, "CFR_FIREFOX_VIEW"); + + const notification = document.querySelector( + "#contextual-feature-recommendation-notification" + ); + + Assert.ok(notification); + Assert.ok(document.querySelector(".popup-notification-primary-button")); + + Assert.ok(document.querySelector(".popup-notification-secondary-button")); + + await notification.button.click(); + + Assert.equal( + specialMessageActionsSpy.firstCall.args[0].type, + "OPEN_FIREFOX_VIEW" + ); + await SpecialPowers.popPrefEnv(); + + closeFirefoxViewTab(window); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_colorways_card.js b/browser/components/firefoxview/tests/browser/browser_colorways_card.js new file mode 100644 index 0000000000..48723905ff --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_colorways_card.js @@ -0,0 +1,443 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { BuiltInThemes } = ChromeUtils.importESModule( + "resource:///modules/BuiltInThemes.sys.mjs" +); +const { AddonManager } = ChromeUtils.import( + "resource://gre/modules/AddonManager.jsm" +); +const { AddonTestUtils } = ChromeUtils.import( + "resource://testing-common/AddonTestUtils.jsm" +); + +const TEST_COLLECTION_FIGURE_URL = "https://www.example.com/collection.avif"; +const TEST_COLORWAY_FIGURE_URL = "https://www.example.com/colorway.avif"; + +const TEST_COLORWAY_COLLECTION = { + id: "independent-voices", + expiry: new Date("3000-01-01"), + l10nId: { + title: "colorway-collection-independent-voices", + description: "colorway-collection-independent-voices-description", + }, + figureUrl: TEST_COLLECTION_FIGURE_URL, +}; + +const SOFT_COLORWAY_THEME_ID = "mocktheme-soft-colorway@mozilla.org"; +const BALANCED_COLORWAY_THEME_ID = "mocktheme-balanced-colorway@mozilla.org"; +const BOLD_COLORWAY_THEME_ID = "mocktheme-bold-colorway@mozilla.org"; +const NO_INTENSITY_COLORWAY_THEME_ID = "mocktheme-colorway@mozilla.org"; +const OUTDATED_COLORWAY_THEME_ID = "outdatedtheme-colorway@mozilla.org"; + +const EXPIRY_DATE_L10N_ID = "colorway-collection-expiry-label"; +const COLORWAY_DESCRIPTION_L10N_ID = "firefoxview-colorway-description"; +const MOCK_THEME_L10N_VALUE = "Mock Theme"; +const SOFT_L10N_VALUE = "Soft"; + +const TRY_COLORWAYS_EVENT = [ + ["colorways_modal", "try_colorways", "firefoxview", undefined], +]; + +const CHANGE_COLORWAY_EVENT = [ + ["colorways_modal", "change_colorway", "firefoxview", undefined], +]; + +function getTestElements(document) { + return { + container: document.getElementById("colorways"), + title: document.getElementById("colorways-collection-title"), + description: document.getElementById("colorways-collection-description"), + expiryPill: document.querySelector("#colorways-collection-expiry-date"), + expiry: document.querySelector("#colorways-collection-expiry-date > span"), + figure: document.getElementById("colorways-collection-figure"), + }; +} + +async function createTempTheme(id) { + const xpi = AddonTestUtils.createTempWebExtensionFile({ + manifest: { + name: "Monochromatic Theme", + browser_specific_settings: { gecko: { id } }, + theme: {}, + }, + }); + return AddonTestUtils.promiseInstallFile(xpi); +} + +let gCollectionEnabled = true; + +// TODO: use Colorway Closet mocks and helper functions (Bug 1783675) + +add_setup(async function setup_tests() { + const sandbox = sinon.createSandbox(); + sandbox + .stub(BuiltInThemes, "findActiveColorwayCollection") + .callsFake(() => (gCollectionEnabled ? TEST_COLORWAY_COLLECTION : null)); + sandbox + .stub(BuiltInThemes, "isColorwayFromCurrentCollection") + .callsFake( + id => + id === SOFT_COLORWAY_THEME_ID || + id === BALANCED_COLORWAY_THEME_ID || + id === BOLD_COLORWAY_THEME_ID || + id === NO_INTENSITY_COLORWAY_THEME_ID + ); + sandbox + .stub(BuiltInThemes, "getLocalizedColorwayGroupName") + .returns(MOCK_THEME_L10N_VALUE); + sandbox.stub(BuiltInThemes.builtInThemeMap, "get").returns({ + figureUrl: TEST_COLORWAY_FIGURE_URL, + }); + await SpecialPowers.pushPrefEnv({ + set: [["browser.theme.colorway-closet", true]], + }); + const tempThemes = await Promise.all( + [ + SOFT_COLORWAY_THEME_ID, + BALANCED_COLORWAY_THEME_ID, + BOLD_COLORWAY_THEME_ID, + NO_INTENSITY_COLORWAY_THEME_ID, + OUTDATED_COLORWAY_THEME_ID, + ].map(createTempTheme) + ); + registerCleanupFunction(async () => { + sandbox.restore(); + await SpecialPowers.popPrefEnv(); + for (const { addon } of tempThemes) { + await addon.disable(); + await addon.uninstall(true); + } + }); +}); + +add_task(async function no_collection_test() { + gCollectionEnabled = false; + try { + await withFirefoxView({ win: window }, async browser => { + const { document } = browser.contentWindow; + const { container } = getTestElements(document); + ok(!BrowserTestUtils.is_visible(container), "Colorways card is hidden"); + }); + } finally { + gCollectionEnabled = true; + } +}); + +add_task(async function colorway_closet_disabled() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.theme.colorway-closet", false]], + }); + try { + await withFirefoxView({ win: window }, async browser => { + const { document } = browser.contentWindow; + const { container } = getTestElements(document); + ok( + !BrowserTestUtils.is_visible(container), + "Colorways card is hidden when Colorway Closet is disabled" + ); + }); + } finally { + await SpecialPowers.popPrefEnv(); + } +}); + +add_task(async function no_active_colorway_test() { + // Set to default theme to unapply any enabled colorways + const theme = await AddonManager.getAddonByID("default-theme@mozilla.org"); + await theme.enable(); + try { + await clearAllParentTelemetryEvents(); + await withFirefoxView({ win: window }, async browser => { + const { document } = browser.contentWindow; + const el = getTestElements(document); + ok( + BrowserTestUtils.is_visible(el.description), + "Colorway description should be visible" + ); + is( + el.figure.src, + TEST_COLLECTION_FIGURE_URL, + "Collection figure should be shown" + ); + is( + document.l10n.getAttributes(el.title).id, + TEST_COLORWAY_COLLECTION.l10nId.title, + "Collection title should be shown" + ); + is( + document.l10n.getAttributes(el.description).id, + TEST_COLORWAY_COLLECTION.l10nId.description, + "Collection description should be shown" + ); + ok(!el.expiryPill.hidden, "Expiry pill is shown"); + const expiryL10nAttributes = document.l10n.getAttributes(el.expiry); + is( + expiryL10nAttributes.args.expiryDate, + TEST_COLORWAY_COLLECTION.expiry.getTime(), + "Correct expiry date should be shown" + ); + is( + expiryL10nAttributes.id, + EXPIRY_DATE_L10N_ID, + "Correct expiry date format should be shown" + ); + + document.querySelector("#colorways-button").click(); + + await TestUtils.waitForCondition( + () => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + let colorwayEvents = events.filter(e => e[1] === "colorways_modal"); + return colorwayEvents && colorwayEvents.length; + }, + "Waiting for try_colorways colorways telemetry event.", + 200, + 100 + ); + + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + let colorwayEvents = events.filter(e => e[1] === "colorways_modal"); + + info(JSON.stringify(colorwayEvents)); + + TelemetryTestUtils.assertEvents( + TRY_COLORWAYS_EVENT, + { category: "colorways_modal" }, + { clear: true, process: "parent" } + ); + }); + } finally { + await theme.disable(); + } +}); + +add_task(async function active_colorway_test() { + const theme = await AddonManager.getAddonByID(SOFT_COLORWAY_THEME_ID); + await theme.enable(); + try { + await clearAllParentTelemetryEvents(); + await withFirefoxView({ win: window }, async browser => { + const { document } = browser.contentWindow; + const el = getTestElements(document); + ok( + BrowserTestUtils.is_visible(el.description), + "Colorway description should be visible" + ); + is( + el.figure.src, + TEST_COLORWAY_FIGURE_URL, + "Colorway figure should be shown" + ); + is( + el.title.textContent, + MOCK_THEME_L10N_VALUE, + "Colorway title should be shown" + ); + const descriptionL10nAttributes = document.l10n.getAttributes( + el.description + ); + is( + descriptionL10nAttributes.id, + COLORWAY_DESCRIPTION_L10N_ID, + "Colorway description should be shown" + ); + is( + descriptionL10nAttributes.args.intensity, + SOFT_L10N_VALUE, + "Colorway intensity should be shown" + ); + is( + descriptionL10nAttributes.args.collection, + "Independent Voices", + "Collection name should be shown" + ); + ok(el.expiryPill.hidden, "Expiry pill is hidden"); + + document.querySelector("#colorways-button").click(); + + await TestUtils.waitForCondition( + () => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + let colorwayEvents = events.filter(e => e[1] === "colorways_modal"); + return colorwayEvents && colorwayEvents.length; + }, + "Waiting for change_colorway colorways telemetry event.", + 200, + 100 + ); + + TelemetryTestUtils.assertEvents( + CHANGE_COLORWAY_EVENT, + { category: "colorways_modal" }, + { clear: true, process: "parent" } + ); + }); + } finally { + await theme.disable(); + } +}); + +add_task(async function active_colorway_without_intensity_test() { + const theme = await AddonManager.getAddonByID(NO_INTENSITY_COLORWAY_THEME_ID); + await theme.enable(); + try { + await withFirefoxView({ win: window }, async browser => { + const { document } = browser.contentWindow; + const el = getTestElements(document); + ok( + BrowserTestUtils.is_visible(el.description), + "Colorway description should be visible" + ); + is( + el.figure.src, + TEST_COLORWAY_FIGURE_URL, + "Colorway figure should be shown" + ); + is( + el.title.textContent, + MOCK_THEME_L10N_VALUE, + "Colorway title should be shown" + ); + is( + document.l10n.getAttributes(el.description).id, + TEST_COLORWAY_COLLECTION.l10nId.title, + "Collection name should be shown as the description" + ); + ok(el.expiryPill.hidden, "Expiry pill is hidden"); + }); + } finally { + await theme.disable(); + } +}); + +add_task(async function active_colorway_is_outdated_test() { + const theme = await AddonManager.getAddonByID(OUTDATED_COLORWAY_THEME_ID); + await theme.enable(); + try { + await withFirefoxView({ win: window }, async browser => { + const { document } = browser.contentWindow; + const el = getTestElements(document); + ok( + BrowserTestUtils.is_visible(el.description), + "Description should be visible" + ); + is( + el.figure.src, + TEST_COLLECTION_FIGURE_URL, + "Collection figure should be shown" + ); + is( + document.l10n.getAttributes(el.title).id, + TEST_COLORWAY_COLLECTION.l10nId.title, + "Collection title should be shown" + ); + is( + document.l10n.getAttributes(el.description).id, + TEST_COLORWAY_COLLECTION.l10nId.description, + "Collection description should be shown" + ); + ok(!el.expiryPill.hidden, "Expiry pill is shown"); + const expiryL10nAttributes = document.l10n.getAttributes(el.expiry); + is( + expiryL10nAttributes.args.expiryDate, + TEST_COLORWAY_COLLECTION.expiry.getTime(), + "Correct expiry date should be shown" + ); + is( + expiryL10nAttributes.id, + EXPIRY_DATE_L10N_ID, + "Correct expiry date format should be shown" + ); + }); + } finally { + await theme.disable(); + } +}); + +add_task(async function change_active_colorway_test() { + let theme = await AddonManager.getAddonByID(NO_INTENSITY_COLORWAY_THEME_ID); + await theme.enable(); + try { + await withFirefoxView({ win: window }, async browser => { + info("Start with no intensity theme"); + const { document } = browser.contentWindow; + let el = getTestElements(document); + ok( + BrowserTestUtils.is_visible(el.description), + "Colorway description should be visible" + ); + is( + el.figure.src, + TEST_COLORWAY_FIGURE_URL, + "Colorway figure should be shown" + ); + is( + el.title.textContent, + MOCK_THEME_L10N_VALUE, + "Colorway title should be shown" + ); + info("Revert to default theme"); + await theme.disable(); + el = getTestElements(document); + ok( + BrowserTestUtils.is_visible(el.description), + "Colorway description should be visible" + ); + is( + el.figure.src, + TEST_COLLECTION_FIGURE_URL, + "Collection figure should be shown" + ); + is( + document.l10n.getAttributes(el.title).id, + TEST_COLORWAY_COLLECTION.l10nId.title, + "Collection title should be shown" + ); + is( + document.l10n.getAttributes(el.description).id, + TEST_COLORWAY_COLLECTION.l10nId.description, + "Collection description should be shown" + ); + info("Enable a different theme"); + theme = await AddonManager.getAddonByID(SOFT_COLORWAY_THEME_ID); + await theme.enable(); + is( + el.title.textContent, + MOCK_THEME_L10N_VALUE, + "Colorway title should be shown" + ); + const descriptionL10nAttributes = document.l10n.getAttributes( + el.description + ); + is( + descriptionL10nAttributes.id, + COLORWAY_DESCRIPTION_L10N_ID, + "Colorway description should be shown" + ); + is( + descriptionL10nAttributes.args.intensity, + SOFT_L10N_VALUE, + "Colorway intensity should be shown" + ); + is( + descriptionL10nAttributes.args.collection, + "Independent Voices", + "Collection name should be shown" + ); + }); + } finally { + await theme.disable(); + } +}); diff --git a/browser/components/firefoxview/tests/browser/browser_dragDrop_after_opening_fxViewTab.js b/browser/components/firefoxview/tests/browser/browser_dragDrop_after_opening_fxViewTab.js new file mode 100644 index 0000000000..e2255fabbf --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_dragDrop_after_opening_fxViewTab.js @@ -0,0 +1,120 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that dragging and dropping tabs into tabbrowser works as intended + * after opening the Firefox View tab for RTL builds. There was an issue where + * tabs from dragged links were not dropped in the correct tab indexes + * for RTL builds because logic for RTL builds did not take into consideration + * hidden tabs like the Firefox View tab. This test makes sure that this behavior does not reoccur. + */ +add_task(async function() { + info("Setting browser to RTL locale"); + await SpecialPowers.pushPrefEnv({ set: [["intl.l10n.pseudo", "bidi"]] }); + + // window.RTL_UI doesn't update in existing windows when this pref is changed, + // so we need to test in a new window. + let win = await BrowserTestUtils.openNewBrowserWindow(); + + const TEST_ROOT = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" + ); + let newTab = win.gBrowser.tabs[0]; + + let waitForTestTabPromise = BrowserTestUtils.waitForNewTab( + win.gBrowser, + TEST_ROOT + "file_new_tab_page.html" + ); + let testTab = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + TEST_ROOT + "file_new_tab_page.html" + ); + await waitForTestTabPromise; + + let linkSrcEl = win.document.querySelector("a"); + ok(linkSrcEl, "Link exists"); + + let dropPromise = BrowserTestUtils.waitForEvent( + win.gBrowser.tabContainer, + "drop" + ); + + /** + * There should be 2 tabs: + * 1. new tab (auto-generated) + * 2. test tab + */ + is(win.gBrowser.visibleTabs.length, 2, "There should be 2 tabs"); + + // Now open Firefox View tab + info("Opening Firefox View tab"); + await openFirefoxViewTab(win); + + /** + * There should be 2 visible tabs: + * 1. new tab (auto-generated) + * 2. test tab + * Firefox View tab is hidden. + */ + is( + win.gBrowser.visibleTabs.length, + 2, + "There should still be 2 visible tabs after opening Firefox View tab" + ); + + info("Switching to test tab"); + await BrowserTestUtils.switchTab(win.gBrowser, testTab); + + let waitForDraggedTabPromise = BrowserTestUtils.waitForNewTab( + win.gBrowser, + "https://example.com/#test" + ); + + info("Dragging link between test tab and new tab"); + EventUtils.synthesizeDrop( + linkSrcEl, + win.gBrowser.tabContainer, + [[{ type: "text/plain", data: "https://example.com/#test" }]], + "link", + win, + win, + { + clientX: testTab.getBoundingClientRect().right, + } + ); + + info("Waiting for drop event"); + await dropPromise; + info("Waiting for dragged tab to be created"); + let draggedTab = await waitForDraggedTabPromise; + + /** + * There should be 3 visible tabs: + * 1. new tab (auto-generated) + * 2. new tab from dragged link + * 3. test tab + * + * In RTL build, it should appear in the following order: + * <test tab> <link dragged tab> <new tab> | <FxView tab> + */ + is(win.gBrowser.visibleTabs.length, 3, "There should be 3 tabs"); + is( + win.gBrowser.visibleTabs.indexOf(newTab), + 0, + "New tab should still be rightmost visible tab" + ); + is( + win.gBrowser.visibleTabs.indexOf(draggedTab), + 1, + "Dragged link should positioned at new index" + ); + is( + win.gBrowser.visibleTabs.indexOf(testTab), + 2, + "Test tab should be to the left of dragged tab" + ); + + await BrowserTestUtils.closeWindow(win); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_entrypoint_management.js b/browser/components/firefoxview/tests/browser/browser_entrypoint_management.js new file mode 100644 index 0000000000..ef6b0c99f5 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_entrypoint_management.js @@ -0,0 +1,67 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_removing_button_should_close_tab() { + await withFirefoxView({}, async browser => { + let win = browser.ownerGlobal; + let tab = browser.getTabBrowser().getTabForBrowser(browser); + let button = win.document.getElementById("firefox-view-button"); + await win.gCustomizeMode.removeFromArea(button, "toolbar-context-menu"); + ok(!tab.isConnected, "Tab should have been removed."); + isnot(win.gBrowser.selectedTab, tab, "A different tab should be selected."); + }); + CustomizableUI.reset(); +}); + +add_task(async function test_button_auto_readd() { + await withFirefoxView({}, async browser => { + let { FirefoxViewHandler } = browser.ownerGlobal; + + CustomizableUI.removeWidgetFromArea("firefox-view-button"); + ok( + !CustomizableUI.getPlacementOfWidget("firefox-view-button"), + "Button has no placement" + ); + ok(!FirefoxViewHandler.tab, "Shouldn't have tab reference"); + ok(!FirefoxViewHandler.button, "Shouldn't have button reference"); + + FirefoxViewHandler.openTab(); + ok(FirefoxViewHandler.tab, "Tab re-opened"); + ok(FirefoxViewHandler.button, "Button re-added"); + let placement = CustomizableUI.getPlacementOfWidget("firefox-view-button"); + is( + placement.area, + CustomizableUI.AREA_TABSTRIP, + "Button re-added to the tabs toolbar" + ); + is(placement.position, 0, "Button re-added as the first toolbar element"); + }); + CustomizableUI.reset(); +}); + +add_task(async function test_button_moved() { + await withFirefoxView({}, async browser => { + let { FirefoxViewHandler } = browser.ownerGlobal; + CustomizableUI.addWidgetToArea( + "firefox-view-button", + CustomizableUI.AREA_NAVBAR, + 0 + ); + is( + FirefoxViewHandler.button.closest("toolbar").id, + "nav-bar", + "Button is in the navigation toolbar" + ); + }); + await withFirefoxView({}, async browser => { + let { FirefoxViewHandler } = browser.ownerGlobal; + is( + FirefoxViewHandler.button.closest("toolbar").id, + "nav-bar", + "Button remains in the navigation toolbar" + ); + }); + CustomizableUI.reset(); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_feature_callout.js b/browser/components/firefoxview/tests/browser/browser_feature_callout.js new file mode 100644 index 0000000000..e90b7ab7ac --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_feature_callout.js @@ -0,0 +1,655 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; +const { MessageLoaderUtils } = ChromeUtils.import( + "resource://activity-stream/lib/ASRouter.jsm" +); + +const { BuiltInThemes } = ChromeUtils.importESModule( + "resource:///modules/BuiltInThemes.sys.mjs" +); + +const featureTourPref = "browser.firefox-view.feature-tour"; +const defaultPrefValue = getPrefValueByScreen(1); + +add_setup(async function() { + requestLongerTimeout(2); + registerCleanupFunction(() => ASRouter.resetMessageState()); +}); + +add_task(async function feature_callout_renders_in_firefox_view() { + await SpecialPowers.pushPrefEnv({ + set: [[featureTourPref, defaultPrefValue]], + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + + ok( + document.querySelector(calloutSelector), + "Feature Callout element exists" + ); + } + ); +}); + +add_task(async function feature_callout_is_not_shown_twice() { + // Third comma-separated value of the pref is set to a string value once a user completes the tour + await SpecialPowers.pushPrefEnv({ + set: [[featureTourPref, '{"message":"","screen":"","complete":true}']], + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + ok( + !document.querySelector(calloutSelector), + "Feature Callout tour does not render if the user finished it previously" + ); + } + ); + Services.prefs.clearUserPref(featureTourPref); +}); + +add_task(async function feature_callout_syncs_across_visits_and_tabs() { + // Second comma-separated value of the pref is the id + // of the last viewed screen of the feature tour + await SpecialPowers.pushPrefEnv({ + set: [[featureTourPref, '{"screen":"FEATURE_CALLOUT_1","complete":false}']], + }); + // Open an about:firefoxview tab + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:firefoxview" + ); + let tab1Doc = tab1.linkedBrowser.contentWindow.document; + await waitForCalloutScreen(tab1Doc, "FEATURE_CALLOUT_1"); + + ok( + tab1Doc.querySelector(".FEATURE_CALLOUT_1"), + "First tab's Feature Callout shows the tour screen saved in the user pref" + ); + + // Open a second about:firefoxview tab + let tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:firefoxview" + ); + let tab2Doc = tab2.linkedBrowser.contentWindow.document; + await waitForCalloutScreen(tab2Doc, "FEATURE_CALLOUT_1"); + + ok( + tab2Doc.querySelector(".FEATURE_CALLOUT_1"), + "Second tab's Feature Callout shows the tour screen saved in the user pref" + ); + + await clickPrimaryButton(tab2Doc); + + gBrowser.selectedTab = tab1; + tab1.focus(); + await waitForCalloutScreen(tab1Doc, "FEATURE_CALLOUT_2"); + ok( + tab1Doc.querySelector(".FEATURE_CALLOUT_2"), + "First tab's Feature Callout advances to the next screen when the tour is advanced in second tab" + ); + + await clickPrimaryButton(tab1Doc); + gBrowser.selectedTab = tab1; + await waitForCalloutRemoved(tab1Doc); + + ok( + !tab1Doc.body.querySelector(calloutSelector), + "Feature Callout is removed in first tab after being dismissed in first tab" + ); + + gBrowser.selectedTab = tab2; + tab2.focus(); + await waitForCalloutRemoved(tab2Doc); + + ok( + !tab2Doc.body.querySelector(calloutSelector), + "Feature Callout is removed in second tab after tour was dismissed in first tab" + ); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); + Services.prefs.clearUserPref(featureTourPref); +}); + +add_task(async function feature_callout_closes_on_dismiss() { + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_FEATURE_TOUR_2_NO_CWS" + ); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + const spy = new TelemetrySpy(sandbox); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + await waitForCalloutScreen(document, "FEATURE_CALLOUT_2"); + + document.querySelector(".dismiss-button").click(); + await waitForCalloutRemoved(document); + + ok( + !document.querySelector(calloutSelector), + "Callout is removed from screen on dismiss" + ); + + let tourComplete = JSON.parse( + Services.prefs.getStringPref(featureTourPref) + ).complete; + ok( + tourComplete, + `Tour is recorded as complete in ${featureTourPref} preference value` + ); + + // Test that appropriate telemetry is sent + spy.assertCalledWith({ + event: "CLICK_BUTTON", + event_context: { + source: "dismiss_button", + page: document.location.href, + }, + message_id: sinon.match("FEATURE_CALLOUT_2"), + }); + spy.assertCalledWith({ + event: "DISMISS", + event_context: { + source: "dismiss_button", + page: document.location.href, + }, + message_id: sinon.match("FEATURE_CALLOUT_2"), + }); + } + ); + sandbox.restore(); +}); + +add_task(async function feature_callout_only_highlights_existing_elements() { + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS" + ); + testMessage.message.content.screens[0].parent_selector = "#fake-selector"; + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + ok( + !document.querySelector(`${calloutSelector}:not(.hidden)`), + "Feature Callout screen does not render if its parent element does not exist" + ); + } + ); + sandbox.restore(); +}); + +add_task(async function feature_callout_arrow_class_exists() { + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS" + ); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + + const arrowParent = document.querySelector(".callout-arrow.arrow-top"); + ok(arrowParent, "Arrow class exists on parent container"); + } + ); + sandbox.restore(); +}); + +add_task(async function feature_callout_arrow_is_not_flipped_on_ltr() { + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS" + ); + testMessage.message.content.screens[0].content.arrow_position = "start"; + testMessage.message.content.screens[0].parent_selector = "span.brand-icon"; + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + await BrowserTestUtils.waitForCondition(() => { + return document.querySelector( + `${calloutSelector}.arrow-inline-start:not(.hidden)` + ); + }); + ok( + true, + "Feature Callout arrow parent has arrow-start class when arrow direction is set to 'start'" + ); + } + ); + sandbox.restore(); +}); + +add_task(async function feature_callout_respects_cfr_features_pref() { + async function toggleCFRFeaturesPref(value, extraPrefs = []) { + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features", + value, + ], + ...extraPrefs, + ], + }); + } + + await toggleCFRFeaturesPref(true, [[featureTourPref, defaultPrefValue]]); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + ok( + document.querySelector(calloutSelector), + "Feature Callout element exists" + ); + + await toggleCFRFeaturesPref(false); + await waitForCalloutRemoved(document); + ok( + !document.querySelector(calloutSelector), + "Feature Callout element was removed because CFR pref was disabled" + ); + } + ); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + ok( + !document.querySelector(calloutSelector), + "Feature Callout element was not created because CFR pref was disabled" + ); + + await toggleCFRFeaturesPref(true); + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + ok( + document.querySelector(calloutSelector), + "Feature Callout element was created because CFR pref was enabled" + ); + } + ); +}); + +add_task( + async function feature_callout_tab_pickup_reminder_primary_click_elm() { + Services.prefs.setBoolPref("identity.fxaccounts.enabled", false); + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_TAB_PICKUP_REMINDER" + ); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + const expectedUrl = await fxAccounts.constructor.config.promiseConnectAccountURI( + "fx-view" + ); + info(`Expected FxA URL: ${expectedUrl}`); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + let tabOpened = new Promise(resolve => { + gBrowser.tabContainer.addEventListener( + "TabOpen", + event => { + let newTab = event.target; + let newBrowser = newTab.linkedBrowser; + let result = newTab; + BrowserTestUtils.waitForDocLoadAndStopIt( + expectedUrl, + newBrowser + ).then(() => resolve(result)); + }, + { once: true } + ); + }); + + info("Waiting for callout to render"); + await waitForCalloutScreen( + document, + "FIREFOX_VIEW_TAB_PICKUP_REMINDER" + ); + + info("Clicking primary button"); + let calloutRemoved = waitForCalloutRemoved(document); + await clickPrimaryButton(document); + let openedTab = await tabOpened; + ok(openedTab, "FxA sign in page opened"); + // The callout should be removed when primary CTA is clicked + await calloutRemoved; + BrowserTestUtils.removeTab(openedTab); + } + ); + Services.prefs.clearUserPref("identity.fxaccounts.enabled"); + sandbox.restore(); + } +); + +add_task(async function feature_callout_dismiss_on_page_click() { + await SpecialPowers.pushPrefEnv({ + set: [[featureTourPref, `{"message":"","screen":"","complete":true}`]], + }); + const screenId = "FIREFOX_VIEW_TAB_PICKUP_REMINDER"; + const testClickSelector = "#recently-closed-tabs-container"; + let testMessage = getCalloutMessageById(screenId); + // Configure message with a dismiss action on tab container click + testMessage.message.content.screens[0].content.page_event_listeners = [ + { + params: { + type: "click", + selectors: testClickSelector, + }, + action: { + dismiss: true, + }, + }, + ]; + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + const spy = new TelemetrySpy(sandbox); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + info("Waiting for callout to render"); + await waitForCalloutScreen(document, screenId); + + info("Clicking page element"); + document.querySelector(testClickSelector).click(); + await waitForCalloutRemoved(document); + + // Test that appropriate telemetry is sent + spy.assertCalledWith({ + event: "PAGE_EVENT", + event_context: { + action: "DISMISS", + reason: "CLICK", + source: sinon.match(testClickSelector), + page: document.location.href, + }, + message_id: screenId, + }); + spy.assertCalledWith({ + event: "DISMISS", + event_context: { + source: sinon + .match("PAGE_EVENT:") + .and(sinon.match(testClickSelector)), + page: document.location.href, + }, + message_id: screenId, + }); + + browser.tabDialogBox + ?.getTabDialogManager() + .dialogs.forEach(dialog => dialog.close()); + } + ); + Services.prefs.clearUserPref("browser.firefox-view.view-count"); + Services.prefs.clearUserPref("identity.fxaccounts.enabled"); + sandbox.restore(); + ASRouter.resetMessageState(); +}); + +add_task(async function feature_callout_advance_tour_on_page_click() { + let sandbox = sinon.createSandbox(); + await SpecialPowers.pushPrefEnv({ + set: [ + [ + featureTourPref, + JSON.stringify({ + message: "FIREFOX_VIEW_FEATURE_TOUR", + screen: "FEATURE_CALLOUT_1", + complete: false, + }), + ], + ], + }); + + // Add page action listeners to the built-in messages. + const TEST_MESSAGES = FeatureCalloutMessages.getMessages().filter(msg => + [ + "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS", + "FIREFOX_VIEW_FEATURE_TOUR_2_NO_CWS", + ].includes(msg.id) + ); + TEST_MESSAGES.forEach(msg => { + let { content } = msg.content.screens[msg.content.startScreen ?? 0]; + content.page_event_listeners = [ + { + params: { + type: "click", + selectors: ".brand-logo", + }, + action: JSON.parse(JSON.stringify(content.primary_button.action)), + }, + ]; + }); + const getMessagesStub = sandbox.stub(FeatureCalloutMessages, "getMessages"); + getMessagesStub.returns(TEST_MESSAGES); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders( + ASRouter.state.providers.filter(p => p.id === "onboarding") + ); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + info("Clicking page button"); + document.querySelector(".brand-logo").click(); + + await waitForCalloutScreen(document, "FEATURE_CALLOUT_2"); + info("Clicking page button"); + document.querySelector(".brand-logo").click(); + + await waitForCalloutRemoved(document); + let tourComplete = JSON.parse( + Services.prefs.getStringPref(featureTourPref) + ).complete; + ok( + tourComplete, + `Tour is recorded as complete in ${featureTourPref} preference value` + ); + } + ); + + sandbox.restore(); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders( + ASRouter.state.providers.filter(p => p.id === "onboarding") + ); +}); + +add_task(async function test_firefox_view_spotlight_promo() { + // Prevent attempts to fetch CFR messages remotely. + const sandbox = sinon.createSandbox(); + let remoteSettingsStub = sandbox.stub( + MessageLoaderUtils, + "_remoteSettingsLoader" + ); + remoteSettingsStub.resolves([]); + + await SpecialPowers.pushPrefEnv({ + clear: [ + [featureTourPref], + ["browser.newtabpage.activity-stream.asrouter.providers.cfr"], + ], + }); + ASRouter.resetMessageState(); + + let dialogOpenPromise = BrowserTestUtils.promiseAlertDialogOpen( + null, + "chrome://browser/content/spotlight.html", + { isSubDialog: true } + ); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + info("Waiting for the Fx View Spotlight promo to open"); + let dialogBrowser = await dialogOpenPromise; + let primaryBtnSelector = ".action-buttons button.primary"; + await TestUtils.waitForCondition( + () => dialogBrowser.document.querySelector("main.DEFAULT_MODAL_UI"), + `Should render main.DEFAULT_MODAL_UI` + ); + + dialogBrowser.document.querySelector(primaryBtnSelector).click(); + info("Fx View Spotlight promo clicked"); + + await BrowserTestUtils.waitForCondition( + () => + browser.contentWindow.performance.navigation.type == + browser.contentWindow.performance.navigation.TYPE_RELOAD + ); + info("Spotlight modal cleared, entering feature tour"); + + const { document } = browser.contentWindow; + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + ok( + document.querySelector(calloutSelector), + "Feature Callout element exists" + ); + info("Feature tour started"); + await clickPrimaryButton(document); + } + ); + + ok(remoteSettingsStub.called, "Tried to load CFR messages"); + sandbox.restore(); + ASRouter.resetMessageState(); +}); + +add_task(async function feature_callout_returns_default_fxview_focus_to_top() { + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS" + ); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + + ok( + document.querySelector(calloutSelector), + "Feature Callout element exists" + ); + + document.querySelector(".dismiss-button").click(); + await waitForCalloutRemoved(document); + + ok( + document.activeElement.localName === "body", + "by default focus returns to the document body after callout closes" + ); + } + ); + sandbox.restore(); +}); + +add_task( + async function feature_callout_returns_moved_fxview_focus_to_previous() { + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_TAB_PICKUP_REMINDER" + ); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + await waitForCalloutScreen( + document, + "FIREFOX_VIEW_TAB_PICKUP_REMINDER" + ); + + // change focus to recently-closed-tabs-container + let recentlyClosedHeaderSection = document.querySelector( + "#recently-closed-tabs-header-section" + ); + recentlyClosedHeaderSection.focus(); + + // close the callout dialog + document.querySelector(".dismiss-button").click(); + await waitForCalloutRemoved(document); + + // verify that the focus landed in the right place + ok( + document.activeElement.id === "recently-closed-tabs-header-section", + "when focus changes away from callout it reverts after callout closes" + ); + } + ); + sandbox.restore(); + } +); diff --git a/browser/components/firefoxview/tests/browser/browser_feature_callout_position.js b/browser/components/firefoxview/tests/browser/browser_feature_callout_position.js new file mode 100644 index 0000000000..8edee7c71b --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_feature_callout_position.js @@ -0,0 +1,403 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +const featureTourPref = "browser.firefox-view.feature-tour"; +const defaultPrefValue = getPrefValueByScreen(1); + +add_task( + async function feature_callout_first_screen_positioned_below_element() { + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS" + ); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + let parentBottom = document + .querySelector("#tab-pickup-container") + .getBoundingClientRect().bottom; + let containerTop = document + .querySelector(calloutSelector) + .getBoundingClientRect().top; + + Assert.lessOrEqual( + parentBottom, + containerTop + 5 + 1, // Add 5px for overlap and 1px for fuzziness to account for possible subpixel rounding + "Feature Callout is positioned below parent element with 5px overlap" + ); + } + ); + sandbox.restore(); + } +); + +add_task( + async function feature_callout_second_screen_positioned_left_of_element() { + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_FEATURE_TOUR_2_NO_CWS" + ); + testMessage.message.content.screens[1].content.arrow_position = "end"; + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + const parent = document.querySelector( + "#recently-closed-tabs-container" + ); + parent.style.gridArea = "1/2"; + await waitForCalloutScreen(document, "FEATURE_CALLOUT_2"); + let parentLeft = parent.getBoundingClientRect().left; + let containerRight = document + .querySelector(calloutSelector) + .getBoundingClientRect().right; + + Assert.greaterOrEqual( + parentLeft, + containerRight - 5 - 1, // Subtract 5px for overlap and 1px for fuzziness to account for possible subpixel rounding + "Feature Callout is positioned left of parent element with 5px overlap" + ); + } + ); + sandbox.restore(); + } +); + +add_task( + async function feature_callout_second_screen_positioned_above_element() { + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_FEATURE_TOUR_2_NO_CWS" + ); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + await waitForCalloutScreen(document, "FEATURE_CALLOUT_2"); + let parentTop = document + .querySelector("#recently-closed-tabs-container") + .getBoundingClientRect().top; + let containerBottom = document + .querySelector(calloutSelector) + .getBoundingClientRect().bottom; + + Assert.greaterOrEqual( + parentTop, + containerBottom - 5 - 1, + "Feature Callout is positioned above parent element with 5px overlap" + ); + } + ); + sandbox.restore(); + } +); + +add_task( + async function feature_callout_third_screen_position_respects_RTL_layouts() { + await SpecialPowers.pushPrefEnv({ + set: [ + // Set layout direction to right to left + ["intl.l10n.pseudo", "bidi"], + ], + }); + + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_FEATURE_TOUR_2_NO_CWS" + ); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + const parent = document.querySelector( + "#recently-closed-tabs-container" + ); + parent.style.gridArea = "1/2"; + await waitForCalloutScreen(document, "FEATURE_CALLOUT_2"); + let parentRight = parent.getBoundingClientRect().right; + let containerLeft = document + .querySelector(calloutSelector) + .getBoundingClientRect().left; + + Assert.lessOrEqual( + parentRight, + containerLeft + 5 + 1, + "Feature Callout is positioned right of parent element when callout is set to 'end' in RTL layouts" + ); + } + ); + + await SpecialPowers.popPrefEnv(); + sandbox.restore(); + } +); + +add_task( + async function feature_callout_is_repositioned_if_parent_container_is_toggled() { + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS" + ); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + const parentEl = document.querySelector("#tab-pickup-container"); + const calloutStartingTopPosition = document.querySelector( + calloutSelector + ).style.top; + + //container has been toggled/minimized + parentEl.removeAttribute("open", ""); + await BrowserTestUtils.waitForMutationCondition( + document.querySelector(calloutSelector), + { attributes: true }, + () => + document.querySelector(calloutSelector).style.top != + calloutStartingTopPosition + ); + isnot( + document.querySelector(calloutSelector).style.top, + calloutStartingTopPosition, + "Feature Callout position is recalculated when parent element is toggled" + ); + await closeCallout(document); + } + ); + sandbox.restore(); + } +); + +// This test should be moved into a surface agnostic test suite with bug 1793656. +add_task(async function feature_callout_top_end_positioning() { + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS" + ); + testMessage.message.content.screens[0].content.arrow_position = "top-end"; + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + let parent = document.querySelector("#tab-pickup-container"); + let container = document.querySelector(calloutSelector); + let parentLeft = parent.getBoundingClientRect().left; + let containerLeft = container.getBoundingClientRect().left; + + ok( + container.classList.contains("arrow-top-end"), + "Feature Callout container has the expected arrow-top-end class" + ); + isfuzzy( + containerLeft - parent.clientWidth + container.offsetWidth, + parentLeft, + 1, // Display scaling can cause up to 1px difference in layout + "Feature Callout's right edge is approximately aligned with parent element's right edge" + ); + + await closeCallout(document); + } + ); + sandbox.restore(); +}); + +// This test should be moved into a surface agnostic test suite with bug 1793656. +add_task(async function feature_callout_top_start_positioning() { + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS" + ); + testMessage.message.content.screens[0].content.arrow_position = "top-start"; + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + let parent = document.querySelector("#tab-pickup-container"); + let container = document.querySelector(calloutSelector); + let parentLeft = parent.getBoundingClientRect().left; + let containerLeft = container.getBoundingClientRect().left; + + ok( + container.classList.contains("arrow-top-start"), + "Feature Callout container has the expected arrow-top-start class" + ); + isfuzzy( + containerLeft, + parentLeft, + 1, // Display scaling can cause up to 1px difference in layout + "Feature Callout's left edge is approximately aligned with parent element's left edge" + ); + + await closeCallout(document); + } + ); + sandbox.restore(); +}); + +// This test should be moved into a surface agnostic test suite with bug 1793656. +add_task( + async function feature_callout_top_end_position_respects_RTL_layouts() { + await SpecialPowers.pushPrefEnv({ + set: [ + // Set layout direction to right to left + ["intl.l10n.pseudo", "bidi"], + ], + }); + + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS" + ); + testMessage.message.content.screens[0].content.arrow_position = "top-end"; + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + let parent = document.querySelector("#tab-pickup-container"); + let container = document.querySelector(calloutSelector); + let parentLeft = parent.getBoundingClientRect().left; + let containerLeft = container.getBoundingClientRect().left; + + ok( + container.classList.contains("arrow-top-start"), + "In RTL mode, the feature Callout container has the expected arrow-top-start class" + ); + is( + containerLeft, + parentLeft, + "In RTL mode, the feature Callout's left edge is aligned with parent element's left edge" + ); + + await closeCallout(document); + } + ); + + await SpecialPowers.popPrefEnv(); + sandbox.restore(); + } +); + +add_task(async function feature_callout_is_larger_than_its_parent() { + let testMessage = { + message: { + id: "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS", + template: "feature_callout", + content: { + id: "FIREFOX_VIEW_FEATURE_TOUR", + transitions: false, + disableHistoryUpdates: true, + screens: [ + { + id: "FEATURE_CALLOUT_1", + parent_selector: ".brand-icon", + content: { + position: "callout", + arrow_position: "end", + title: "callout-firefox-view-tab-pickup-title", + subtitle: { + string_id: "callout-firefox-view-tab-pickup-subtitle", + }, + logo: { + imageURL: "chrome://browser/content/callout-tab-pickup.svg", + darkModeImageURL: + "chrome://browser/content/callout-tab-pickup-dark.svg", + height: "128px", // .brand-icon has a height of 32px + }, + dismiss_button: { + action: { + navigate: true, + }, + }, + }, + }, + ], + }, + }, + }; + + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await SpecialPowers.pushPrefEnv({ + set: [[featureTourPref, getPrefValueByScreen(1)]], + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + let parent = document.querySelector(".brand-icon"); + let container = document.querySelector(calloutSelector); + let parentHeight = parent.offsetHeight; + let containerHeight = container.offsetHeight; + + let parentPositionTop = + parent.getBoundingClientRect().top + window.scrollY; + let containerPositionTop = + container.getBoundingClientRect().top + window.scrollY; + Assert.greater( + containerHeight, + parentHeight, + "Feature Callout is height is larger than parent element when callout is configured at end of callout" + ); + Assert.less( + containerPositionTop, + parentPositionTop, + "Feature Callout is positioned higher that parent element when callout is configured at end of callout" + ); + isfuzzy( + containerHeight / 2 + containerPositionTop, + parentHeight / 2 + parentPositionTop, + 1, // Display scaling can cause up to 1px difference in layout + "Feature Callout is centered equally to parent element when callout is configured at end of callout" + ); + await ASRouter.resetMessageState(); + } + ); + sandbox.restore(); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_feature_callout_resize.js b/browser/components/firefoxview/tests/browser/browser_feature_callout_resize.js new file mode 100644 index 0000000000..1f9d00975a --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_feature_callout_resize.js @@ -0,0 +1,127 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const featureTourPref = "browser.firefox-view.feature-tour"; + +add_setup(async function setup() { + let originalWidth = window.outerWidth; + let originalHeight = window.outerHeight; + registerCleanupFunction(async () => { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:firefoxview" }, + async browser => window.FullZoom.reset(browser) + ); + window.resizeTo(originalWidth, originalHeight); + }); + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:firefoxview" }, + async browser => window.FullZoom.setZoom(0.5, browser) + ); +}); + +add_task(async function feature_callout_is_repositioned_if_it_does_not_fit() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.sessionstore.max_tabs_undo", 1]], + }); + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS" + ); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:firefoxview" }, + async browser => { + const { document } = browser.contentWindow; + + browser.contentWindow.resizeTo(1550, 1000); + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + ok( + document.querySelector(`${calloutSelector}.arrow-top`), + "On first screen at 1550x1000, the callout is positioned below the parent element" + ); + + let startingTop = document.querySelector(calloutSelector).style.top; + browser.contentWindow.resizeTo(1600, 400); + // Wait for callout to be repositioned + await BrowserTestUtils.waitForMutationCondition( + document.querySelector(calloutSelector), + { attributeFilter: ["style"], attributes: true }, + () => document.querySelector(calloutSelector).style.top != startingTop + ); + ok( + document.querySelector(`${calloutSelector}.arrow-inline-start`), + "On first screen at 1600x400, the callout is positioned to the right of the parent element" + ); + + startingTop = document.querySelector(calloutSelector).style.top; + browser.contentWindow.resizeTo(1100, 600); + await BrowserTestUtils.waitForMutationCondition( + document.querySelector(calloutSelector), + { attributeFilter: ["style"], attributes: true }, + () => document.querySelector(calloutSelector).style.top != startingTop + ); + ok( + document.querySelector(`${calloutSelector}.arrow-top`), + "On first screen at 1100x600, the callout is positioned below the parent element" + ); + } + ); + sandbox.restore(); +}); + +add_task(async function feature_callout_is_repositioned_rtl() { + await SpecialPowers.pushPrefEnv({ + set: [ + // Set layout direction to right to left + ["intl.l10n.pseudo", "bidi"], + ["browser.sessionstore.max_tabs_undo", 1], + ], + }); + + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS" + ); + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:firefoxview" }, + async browser => { + const { document } = browser.contentWindow; + + browser.contentWindow.resizeTo(1550, 1000); + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + ok( + document.querySelector(`${calloutSelector}.arrow-top`), + "On first screen at 1550x1000, the callout is positioned below the parent element" + ); + + let startingTop = document.querySelector(calloutSelector).style.top; + browser.contentWindow.resizeTo(1600, 400); + // Wait for callout to be repositioned + await BrowserTestUtils.waitForMutationCondition( + document.querySelector(calloutSelector), + { attributeFilter: ["style"], attributes: true }, + () => document.querySelector(calloutSelector).style.top != startingTop + ); + ok( + document.querySelector(`${calloutSelector}.arrow-inline-end`), + "On first screen at 1600x400, the callout is positioned to the right of the parent element" + ); + + startingTop = document.querySelector(calloutSelector).style.top; + browser.contentWindow.resizeTo(1100, 600); + await BrowserTestUtils.waitForMutationCondition( + document.querySelector(calloutSelector), + { attributeFilter: ["style"], attributes: true }, + () => document.querySelector(calloutSelector).style.top != startingTop + ); + ok( + document.querySelector(`${calloutSelector}.arrow-top`), + "On first screen at 1100x600, the callout is positioned below the parent element" + ); + } + ); + sandbox.restore(); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_feature_callout_targeting.js b/browser/components/firefoxview/tests/browser/browser_feature_callout_targeting.js new file mode 100644 index 0000000000..ccba3e4560 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_feature_callout_targeting.js @@ -0,0 +1,175 @@ +"use strict"; + +const { BuiltInThemes } = ChromeUtils.importESModule( + "resource:///modules/BuiltInThemes.sys.mjs" +); + +const { AddonManager } = ChromeUtils.import( + "resource://gre/modules/AddonManager.jsm" +); + +add_task( + async function test_firefox_view_tab_pick_up_not_signed_in_targeting() { + ASRouter.resetMessageState(); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.firefox-view.feature-tour", `{"screen":"","complete":true}`], + ], + }); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.firefox-view.view-count", 3]], + }); + + await SpecialPowers.pushPrefEnv({ + set: [["identity.fxaccounts.enabled", false]], + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + await waitForCalloutScreen( + document, + "FIREFOX_VIEW_TAB_PICKUP_REMINDER" + ); + ok( + document.querySelector(".featureCallout"), + "Firefox:View Tab Pickup should be displayed." + ); + + SpecialPowers.popPrefEnv(); + SpecialPowers.popPrefEnv(); + SpecialPowers.popPrefEnv(); + } + ); + } +); + +add_task( + async function test_firefox_view_tab_pick_up_sync_not_enabled_targeting() { + ASRouter.resetMessageState(); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.firefox-view.feature-tour", `{"screen":"","complete":true}`], + ], + }); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.firefox-view.view-count", 3]], + }); + + await SpecialPowers.pushPrefEnv({ + set: [["identity.fxaccounts.enabled", true]], + }); + + await SpecialPowers.pushPrefEnv({ + set: [["services.sync.engine.tabs", false]], + }); + + await SpecialPowers.pushPrefEnv({ + set: [["services.sync.username", false]], + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + await waitForCalloutScreen( + document, + "FIREFOX_VIEW_TAB_PICKUP_REMINDER" + ); + ok( + document.querySelector(".featureCallout"), + "Firefox:View Tab Pickup should be displayed." + ); + + SpecialPowers.popPrefEnv(); + SpecialPowers.popPrefEnv(); + SpecialPowers.popPrefEnv(); + SpecialPowers.popPrefEnv(); + SpecialPowers.popPrefEnv(); + } + ); + } +); + +add_task( + async function test_firefox_view_tab_pick_up_wait_24_hours_after_spotlight() { + const TWENTY_FIVE_HOURS_IN_MS = 25 * 60 * 60 * 1000; + + ASRouter.resetMessageState(); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.firefox-view.feature-tour", `{"screen":"","complete":true}`], + ], + }); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.firefox-view.view-count", 3]], + }); + + await SpecialPowers.pushPrefEnv({ + set: [["identity.fxaccounts.enabled", false]], + }); + + ASRouter.setState({ + messageImpressions: { FIREFOX_VIEW_SPOTLIGHT: [Date.now()] }, + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + ok( + !document.querySelector(".featureCallout"), + "Tab Pickup reminder should not be displayed when the Spotlight message introducing the tour was viewed less than 24 hours ago." + ); + } + ); + + ASRouter.setState({ + messageImpressions: { + FIREFOX_VIEW_SPOTLIGHT: [Date.now() - TWENTY_FIVE_HOURS_IN_MS], + }, + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + + await waitForCalloutScreen( + document, + "FIREFOX_VIEW_TAB_PICKUP_REMINDER" + ); + ok( + document.querySelector(".featureCallout"), + "Tab Pickup reminder can be displayed when the Spotlight message introducing the tour was viewed over 24 hours ago." + ); + + SpecialPowers.popPrefEnv(); + SpecialPowers.popPrefEnv(); + SpecialPowers.popPrefEnv(); + } + ); + } +); diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview.js b/browser/components/firefoxview/tests/browser/browser_firefoxview.js new file mode 100644 index 0000000000..5ac9dd4c7b --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_firefoxview.js @@ -0,0 +1,18 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function about_firefoxview_smoke_test() { + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + // sanity check the important regions exist on this page + ok( + document.getElementById("tab-pickup-container"), + "tab-pickup-container element exists" + ); + ok( + document.getElementById("recently-closed-tabs-container"), + "recently-closed-tabs-container element exists" + ); + }); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview_accessibility.js b/browser/components/firefoxview/tests/browser/browser_firefoxview_accessibility.js new file mode 100644 index 0000000000..e4b2e866a0 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_accessibility.js @@ -0,0 +1,110 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that are related to the accessibility of the Firefox View + * document. These tasks tend to be privileged content, not browser + * chrome. + */ + +add_setup(async function setup() { + // Make sure the prompt to connect FxA doesn't show + // Without resetting the view-count pref it gets surfaced after + // the third click on the fx view toolbar button. + await SpecialPowers.pushPrefEnv({ + set: [["browser.firefox-view.view-count", 0]], + }); +}); + +add_task(async function test_keyboard_focus_after_tab_pickup_opened() { + // Reset various things touched by other tests in this file so that + // we have a sufficiently clean environment. + + TabsSetupFlowManager.resetInternalState(); + + // Ensure that the tab-pickup section doesn't need to be opened. + Services.prefs.clearUserPref( + "browser.tabs.firefox-view.ui-state.tab-pickup.open" + ); + + // make sure the feature tour doesn't get in the way + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.firefox-view.feature-tour", + JSON.stringify({ + screen: `FEATURE_CALLOUT_1`, + complete: true, + }), + ], + ], + }); + + // Let's be deterministic about the basic UI state! + const sandbox = setupMocks({ + state: UIState.STATUS_NOT_CONFIGURED, + syncEnabled: false, + }); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + let win = browser.ownerGlobal; + + is( + document.activeElement.localName, + "body", + "document body element is initially focused" + ); + + const tab = () => { + info("Tab keypress synthesized"); + EventUtils.synthesizeKey("KEY_Tab", {}, win); + }; + + tab(); + + let tabPickupContainer = document.querySelector( + "#tab-pickup-container summary.page-section-header" + ); + is( + document.activeElement, + tabPickupContainer, + "tab pickup container header has focus" + ); + + tab(); + + is( + document.activeElement.id, + "firefoxview-tabpickup-step-signin-primarybutton", + "tab pickup primary button has focus" + ); + }); + + // cleanup time + await tearDown(sandbox); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_keyboard_accessibility_tab_pickup() { + await withFirefoxView({}, async browser => { + const win = browser.ownerGlobal; + const { document } = browser.contentWindow; + const enter = async () => { + info("Enter"); + EventUtils.synthesizeKey("KEY_Enter", {}, win); + }; + let details = document.getElementById("tab-pickup-container"); + let summary = details.querySelector("summary"); + ok(summary, "summary element should exist"); + ok(details.open, "Tab pickup container should be initially open on load"); + summary.focus(); + await enter(); + ok(!details.open, "Tab pickup container should be closed"); + await enter(); + ok(details.open, "Tab pickup container should be opened"); + }); + cleanup_tab_pickup(); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview_feature_callout_a11y.js b/browser/components/firefoxview/tests/browser/browser_firefoxview_feature_callout_a11y.js new file mode 100644 index 0000000000..17485e54e1 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_feature_callout_a11y.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that are related to the accessibility of the feature callout + */ + +/** + * Ensure feature tour is accessible using a screen reader and with + * keyboard navigation. + */ +add_task(async function feature_callout_is_accessible() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.firefox-view.feature-tour", getPrefValueByScreen(1)]], + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + await waitForCalloutScreen(document, "FEATURE_CALLOUT_1"); + + await BrowserTestUtils.waitForCondition( + () => document.activeElement.id === calloutId, + "Feature Callout is focused on page load" + ); + ok(true, "Feature Callout was focused on page load"); + + await BrowserTestUtils.waitForCondition( + () => + document.querySelector( + `${calloutSelector}[aria-describedby="#${calloutId} .welcome-text"]` + ), + "The callout container has an aria-describedby value equal to the screen welcome text" + ); + ok(true, "The callout container has the correct aria-describedby value"); + + // Advance to second screen + clickPrimaryButton(document); + await waitForCalloutScreen(document, "FEATURE_CALLOUT_2"); + + ok(true, "FEATURE_CALLOUT_2 was successfully displayed"); + await BrowserTestUtils.waitForCondition( + () => document.activeElement.id === calloutId, + "Feature Callout is focused after advancing screens" + ); + ok(true, "Feature Callout was successfully focused"); + } + ); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview_tab.js b/browser/components/firefoxview/tests/browser/browser_firefoxview_tab.js new file mode 100644 index 0000000000..f178ad4f32 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_tab.js @@ -0,0 +1,249 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +async function expectFocusAfterKey( + aKey, + aFocus, + aAncestorOk = false, + aWindow = window +) { + let res = aKey.match(/^(Shift\+)?(?:(.)|(.+))$/); + let shift = Boolean(res[1]); + let key; + if (res[2]) { + key = res[2]; // Character. + } else { + key = "KEY_" + res[3]; // Tab, ArrowRight, etc. + } + let expected; + let friendlyExpected; + if (typeof aFocus == "string") { + expected = aWindow.document.getElementById(aFocus); + friendlyExpected = aFocus; + } else { + expected = aFocus; + if (aFocus == aWindow.gURLBar.inputField) { + friendlyExpected = "URL bar input"; + } else if (aFocus == aWindow.gBrowser.selectedBrowser) { + friendlyExpected = "Web document"; + } + } + info("Listening on item " + (expected.id || expected.className)); + let focused = BrowserTestUtils.waitForEvent(expected, "focus", aAncestorOk); + EventUtils.synthesizeKey(key, { shiftKey: shift }, aWindow); + let receivedEvent = await focused; + info( + "Got focus on item: " + + (receivedEvent.target.id || receivedEvent.target.className) + ); + ok(true, friendlyExpected + " focused after " + aKey + " pressed"); +} + +function forceFocus(aElem) { + aElem.setAttribute("tabindex", "-1"); + aElem.focus(); + aElem.removeAttribute("tabindex"); +} + +add_task(async function aria_attributes() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + is( + win.FirefoxViewHandler.button.getAttribute("role"), + "button", + "Firefox View button should have the 'button' ARIA role" + ); + await openFirefoxViewTab(win); + isnot( + win.FirefoxViewHandler.button.getAttribute("aria-controls"), + "", + "Firefox View button should have non-empty `aria-controls` attribute" + ); + is( + win.FirefoxViewHandler.button.getAttribute("aria-controls"), + win.FirefoxViewHandler.tab.linkedPanel, + "Firefox View button should refence the hidden tab's linked panel via `aria-controls`" + ); + is( + win.FirefoxViewHandler.button.getAttribute("aria-pressed"), + "true", + 'Firefox View button should have `aria-pressed="true"` upon selecting it' + ); + win.BrowserOpenTab(); + is( + win.FirefoxViewHandler.button.getAttribute("aria-pressed"), + "false", + 'Firefox View button should have `aria-pressed="false"` upon selecting a different tab' + ); + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function load_opens_new_tab() { + await withFirefoxView({}, async browser => { + let win = browser.ownerGlobal; + ok(win.FirefoxViewHandler.tab.selected, "Firefox View tab is selected"); + win.gURLBar.focus(); + win.gURLBar.value = "https://example.com"; + let newTabOpened = BrowserTestUtils.waitForEvent( + win.gBrowser.tabContainer, + "TabOpen" + ); + EventUtils.synthesizeKey("KEY_Enter", {}, win); + info( + "Waiting for new tab to open from the address bar in the Firefox View tab" + ); + await newTabOpened; + assertFirefoxViewTab(win); + ok( + !win.FirefoxViewHandler.tab.selected, + "Firefox View tab is not selected anymore (new tab opened in the foreground)" + ); + }); +}); + +add_task(async function homepage_new_tab() { + await withFirefoxView({}, async browser => { + let win = browser.ownerGlobal; + ok(win.FirefoxViewHandler.tab.selected, "Firefox View tab is selected"); + let newTabOpened = BrowserTestUtils.waitForEvent( + win.gBrowser.tabContainer, + "TabOpen" + ); + win.BrowserHome(); + info("Waiting for BrowserHome() to open a new tab"); + await newTabOpened; + assertFirefoxViewTab(win); + ok( + !win.FirefoxViewHandler.tab.selected, + "Firefox View tab is not selected anymore (home page opened in the foreground)" + ); + }); +}); + +add_task(async function number_tab_select_shortcut() { + await withFirefoxView({}, async browser => { + let win = browser.ownerGlobal; + EventUtils.synthesizeKey( + "1", + AppConstants.MOZ_WIDGET_GTK ? { altKey: true } : { accelKey: true }, + win + ); + ok( + !win.FirefoxViewHandler.tab.selected, + "Number shortcut to select the first tab skipped the Firefox View tab" + ); + }); +}); + +add_task(async function accel_w_behavior() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + await openFirefoxViewTab(win); + EventUtils.synthesizeKey("w", { accelKey: true }, win); + ok(!win.FirefoxViewHandler.tab, "Accel+w closed the Firefox View tab"); + await openFirefoxViewTab(win); + win.gBrowser.selectedTab = win.gBrowser.visibleTabs[0]; + info( + "Waiting for Accel+W in the only visible tab to close the window, ignoring the presence of the hidden Firefox View tab" + ); + let windowClosed = BrowserTestUtils.windowClosed(win); + EventUtils.synthesizeKey("w", { accelKey: true }, win); + await windowClosed; +}); + +add_task(async function undo_close_tab() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + Services.obs.notifyObservers(null, "browser:purge-session-history"); + is( + SessionStore.getClosedTabCount(win), + 0, + "Closed tab count after purging session history" + ); + + let tab = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + "about:about" + ); + await TestUtils.waitForTick(); + + let sessionUpdatePromise = BrowserTestUtils.waitForSessionStoreUpdate(tab); + win.gBrowser.removeTab(tab); + await sessionUpdatePromise; + is( + SessionStore.getClosedTabCount(win), + 1, + "Closing about:about added to the closed tab count" + ); + + let viewTab = await openFirefoxViewTab(win); + await TestUtils.waitForTick(); + sessionUpdatePromise = BrowserTestUtils.waitForSessionStoreUpdate(viewTab); + closeFirefoxViewTab(win); + await sessionUpdatePromise; + is( + SessionStore.getClosedTabCount(win), + 1, + "Closing the Firefox View tab did not add to the closed tab count" + ); + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_firefoxview_view_count() { + const startViews = 2; + await SpecialPowers.pushPrefEnv({ + set: [["browser.firefox-view.view-count", startViews]], + }); + + let tab = await openFirefoxViewTab(window); + + ok( + SpecialPowers.getIntPref("browser.firefox-view.view-count") === + startViews + 1, + "View count pref value is incremented when tab is selected" + ); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_add_ons_cant_unhide_fx_view() { + // Test that add-ons can't unhide the Firefox View tab by calling + // browser.tabs.show(). See bug 1791770 for details. + let win = await BrowserTestUtils.openNewBrowserWindow(); + let tab = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + "about:about" + ); + let viewTab = await openFirefoxViewTab(win); + win.gBrowser.hideTab(tab); + + ok(tab.hidden, "Regular tab is hidden"); + ok(viewTab.hidden, "Firefox View tab is hidden"); + + win.gBrowser.showTab(tab); + win.gBrowser.showTab(viewTab); + + ok(!tab.hidden, "Add-on showed regular hidden tab"); + ok(viewTab.hidden, "Add-on did not show Firefox View tab"); + + await BrowserTestUtils.closeWindow(win); +}); + +// Test navigation to first visible tab when the +// Firefox View button is present and active. +add_task(async function testFirstTabFocusableWhenFxViewOpen() { + await SpecialPowers.pushPrefEnv({ set: [["accessibility.tabfocus", 7]] }); + await withFirefoxView({}, async browser => { + let win = browser.ownerGlobal; + ok(win.FirefoxViewHandler.tab.selected, "Firefox View tab is selected"); + let fxViewBtn = win.document.getElementById("firefox-view-button"); + forceFocus(fxViewBtn); + is( + win.document.activeElement, + fxViewBtn, + "Firefox View button focused for start of test" + ); + let firstVisibleTab = win.gBrowser.visibleTabs[0]; + await expectFocusAfterKey("Tab", firstVisibleTab, false, win); + let activeElement = win.document.activeElement; + let expectedElement = firstVisibleTab; + is(activeElement, expectedElement, "First visible tab should be focused"); + }); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_keyboard_focus.js b/browser/components/firefoxview/tests/browser/browser_keyboard_focus.js new file mode 100644 index 0000000000..aaeb2b7792 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_keyboard_focus.js @@ -0,0 +1,93 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { TabsSetupFlowManager } = ChromeUtils.importESModule( + "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs" +); + +XPCOMUtils.defineLazyModuleGetters(globalThis, { + SyncedTabs: "resource://services-sync/SyncedTabs.jsm", +}); + +const SYNCED_URI = syncedTabsData1[0].tabs[1].url; + +add_task(async function test_keyboard_focus() { + await SpecialPowers.pushPrefEnv({ + set: [["accessibility.tabfocus", 7]], + }); + + await withFirefoxView({ win: window }, async browser => { + const { document } = browser.contentWindow; + + const sandbox = setupRecentDeviceListMocks(); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + let mockTabs1 = getMockTabData(syncedTabsData1); + syncedTabsMock.returns(mockTabs1); + + await setupListState(browser); + + testVisibility(browser, { + expectedVisible: { + "ol.synced-tabs-list": true, + }, + }); + + let tabPickupEle = document.querySelector(".synced-tab-a"); + document.querySelector(".page-section-header").focus(); + + EventUtils.synthesizeKey("KEY_Tab"); + + is( + tabPickupEle, + document.activeElement, + "The first tab pickup link is focused" + ); + + let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, SYNCED_URI); + EventUtils.synthesizeKey("KEY_Enter"); + await newTabPromise; + + is( + SYNCED_URI, + gBrowser.selectedBrowser.currentURI.displaySpec, + "We opened the tab via keyboard" + ); + + let sessionStorePromise = BrowserTestUtils.waitForSessionStoreUpdate( + gBrowser.selectedTab + ); + gBrowser.removeTab(gBrowser.selectedTab); + await sessionStorePromise; + + window.FirefoxViewHandler.openTab(); + + let recentlyClosedEle = document.querySelector(".closed-tab-li-main"); + document.querySelectorAll(".page-section-header")[1].focus(); + + EventUtils.synthesizeKey("KEY_Tab"); + + is( + recentlyClosedEle, + document.activeElement, + "The recently closed tab is focused" + ); + + newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, SYNCED_URI); + EventUtils.synthesizeKey("KEY_Enter"); + await newTabPromise; + is( + SYNCED_URI, + gBrowser.selectedBrowser.currentURI.displaySpec, + "We opened the tab via keyboard" + ); + gBrowser.removeTab(gBrowser.selectedTab); + + sessionStorePromise = TestUtils.topicObserved( + "sessionstore-closed-objects-changed" + ); + SessionStore.forgetClosedTab(window, 0); + await sessionStorePromise; + + sandbox.restore(); + }); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_media_query_dom_sorting.js b/browser/components/firefoxview/tests/browser/browser_media_query_dom_sorting.js new file mode 100644 index 0000000000..e10158504b --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_media_query_dom_sorting.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const WIDE_WINDOW_WIDTH = 1100; +const NARROW_WINDOW_WIDTH = 900; + +function getTestElements(doc) { + return { + recentlyClosedTabs: doc.getElementById("recently-closed-tabs-container"), + colorways: doc.getElementById("colorways"), + }; +} + +function iscolorwaysBeforeRecentlyClosedTabs(document) { + const recentlyClosedTabs = document.getElementById( + "recently-closed-tabs-container" + ); + const colorways = document.getElementById("colorways"); + return recentlyClosedTabs.previousElementSibling === colorways; +} + +async function resizeWindow(win, width) { + const resizePromise = BrowserTestUtils.waitForEvent(win, "resize"); + win.windowUtils.ensureDirtyRootFrame(); + info("Resizing window..."); + win.resizeTo(width, win.outerHeight); + await resizePromise; +} + +add_task(async function media_query_less_than_65em() { + await withFirefoxView({}, async browser => { + let win = browser.contentWindow; + const { recentlyClosedTabs, colorways } = getTestElements(win.document); + await resizeWindow(win, NARROW_WINDOW_WIDTH); + is( + recentlyClosedTabs.previousSibling, + colorways, + "colorway card has been positioned before recently closed tabs" + ); + }); +}); + +add_task(async function media_query_more_than_65em() { + await withFirefoxView({}, async browser => { + let win = browser.contentWindow; + const { recentlyClosedTabs, colorways } = getTestElements(win.document); + await resizeWindow(win, WIDE_WINDOW_WIDTH); + is( + recentlyClosedTabs.nextSibling, + colorways, + "colorway card has been positioned after recently closed tabs" + ); + }); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_notification_dot.js b/browser/components/firefoxview/tests/browser/browser_notification_dot.js new file mode 100644 index 0000000000..729377bf8d --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_notification_dot.js @@ -0,0 +1,335 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const tabsList1 = syncedTabsData1[0].tabs; +const tabsList2 = syncedTabsData1[1].tabs; +const BADGE_TOP_RIGHT = "75% 25%"; + +const { SyncedTabs } = ChromeUtils.import( + "resource://services-sync/SyncedTabs.jsm" +); + +const { FirefoxViewNotificationManager } = ChromeUtils.importESModule( + "resource:///modules/firefox-view-notification-manager.sys.mjs" +); + +function setupRecentDeviceListMocks() { + const sandbox = sinon.createSandbox(); + sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => [ + { + id: 1, + name: "My desktop", + isCurrentDevice: true, + type: "desktop", + }, + { + id: 2, + name: "My iphone", + type: "mobile", + }, + ]); + + sandbox.stub(UIState, "get").returns({ + status: UIState.STATUS_SIGNED_IN, + syncEnabled: true, + }); + + return sandbox; +} + +function waitForWindowActive(win, active) { + info("Waiting for window activation"); + return Promise.all([ + BrowserTestUtils.waitForEvent(win, active ? "focus" : "blur"), + BrowserTestUtils.waitForEvent(win, active ? "activate" : "deactivate"), + ]); +} + +async function waitForNotificationBadgeToBeShowing(fxViewButton) { + info("Waiting for attention attribute to be set"); + await BrowserTestUtils.waitForMutationCondition( + fxViewButton, + { attributes: true }, + () => fxViewButton.hasAttribute("attention") + ); + return fxViewButton.hasAttribute("attention"); +} + +async function waitForNotificationBadgeToBeHidden(fxViewButton) { + info("Waiting for attention attribute to be removed"); + await BrowserTestUtils.waitForMutationCondition( + fxViewButton, + { attributes: true }, + () => !fxViewButton.hasAttribute("attention") + ); + return !fxViewButton.hasAttribute("attention"); +} + +function getBackgroundPositionForElement(ele) { + let style = ele.ownerGlobal.getComputedStyle(ele); + return style.getPropertyValue("background-position"); +} + +let recentFetchTime = Math.floor(Date.now() / 1000); +async function initTabSync() { + recentFetchTime += 1; + info("updating lastFetch:" + recentFetchTime); + Services.prefs.setIntPref("services.sync.lastTabFetch", recentFetchTime); + await TestUtils.waitForTick(); +} + +add_setup(async function() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.tabs.firefox-view.notify-for-tabs", true]], + }); + + // Clear any synced tabs from previous tests + FirefoxViewNotificationManager.syncedTabs = null; + Services.obs.notifyObservers( + null, + "firefoxview-notification-dot-update", + "false" + ); +}); + +/** + * Test that the notification badge will show and hide in the correct cases + */ +add_task(async function testNotificationDot() { + const sandbox = setupRecentDeviceListMocks(); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + sandbox.spy(SyncedTabs, "syncTabs"); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + let fxViewBtn = win.document.getElementById("firefox-view-button"); + ok(fxViewBtn, "Got the Firefox View button"); + + // Initiate a synced tabs update with new tabs + syncedTabsMock.returns(tabsList1); + await initTabSync(); + + ok( + BrowserTestUtils.is_visible(fxViewBtn), + "The Firefox View button is showing" + ); + + ok( + await waitForNotificationBadgeToBeHidden(fxViewBtn), + "The notification badge is not showing initially" + ); + + // Initiate a synced tabs update with new tabs + syncedTabsMock.returns(tabsList2); + await initTabSync(); + + ok( + await waitForNotificationBadgeToBeShowing(fxViewBtn), + "The notification badge is showing after first tab sync" + ); + + // check that switching to the firefoxviewtab removes the badge + fxViewBtn.click(); + + ok( + await waitForNotificationBadgeToBeHidden(fxViewBtn), + "The notification badge is not showing after going to Firefox View" + ); + + await BrowserTestUtils.waitForCondition(() => { + return SyncedTabs.syncTabs.calledOnce; + }); + + ok(SyncedTabs.syncTabs.calledOnce, "SyncedTabs.syncTabs() was called once"); + + syncedTabsMock.returns(tabsList1); + // Initiate a synced tabs update with new tabs + await initTabSync(); + + // The noti badge would show but we are on a Firefox View page so no need to show the noti badge + ok( + await waitForNotificationBadgeToBeHidden(fxViewBtn), + "The notification badge is not showing after tab sync while Firefox View is focused" + ); + + let newTab = await BrowserTestUtils.openNewForegroundTab(win.gBrowser); + syncedTabsMock.returns(tabsList2); + await initTabSync(); + + ok( + await waitForNotificationBadgeToBeShowing(fxViewBtn), + "The notification badge is showing after navigation to a new tab" + ); + + // check that switching back to the Firefox View tab removes the badge + fxViewBtn.click(); + + ok( + await waitForNotificationBadgeToBeHidden(fxViewBtn), + "The notification badge is not showing after focusing the Firefox View tab" + ); + + await BrowserTestUtils.switchTab(win.gBrowser, newTab); + + // Initiate a synced tabs update with no new tabs + await initTabSync(); + + ok( + await waitForNotificationBadgeToBeHidden(fxViewBtn), + "The notification badge is not showing after a tab sync with the same tabs" + ); + + await BrowserTestUtils.closeWindow(win); + + sandbox.restore(); +}); + +/** + * Tests the notification badge with multiple windows + */ +add_task(async function testNotificationDotOnMultipleWindows() { + const sandbox = setupRecentDeviceListMocks(); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + + // Create a new window + let win1 = await BrowserTestUtils.openNewBrowserWindow(); + await win1.delayedStartupPromise; + let fxViewBtn = win1.document.getElementById("firefox-view-button"); + ok(fxViewBtn, "Got the Firefox View button"); + + syncedTabsMock.returns(tabsList1); + // Initiate a synced tabs update + await initTabSync(); + + // Create another window + let win2 = await BrowserTestUtils.openNewBrowserWindow(); + await win2.delayedStartupPromise; + let fxViewBtn2 = win2.document.getElementById("firefox-view-button"); + + fxViewBtn2.click(); + + // Make sure the badge doesn't show on any window + ok( + await waitForNotificationBadgeToBeHidden(fxViewBtn), + "The notification badge is not showing in the inital window" + ); + ok( + await waitForNotificationBadgeToBeHidden(fxViewBtn2), + "The notification badge is not showing in the second window" + ); + + // Minimize the window. + win2.minimize(); + + await TestUtils.waitForCondition( + () => !win2.gBrowser.selectedBrowser.docShellIsActive, + "Waiting for docshell to be marked as inactive after minimizing the window" + ); + + syncedTabsMock.returns(tabsList2); + info("Initiate a synced tabs update with new tabs"); + await initTabSync(); + + // The badge will show because the View tab is minimized + // Make sure the badge shows on all windows + ok( + await waitForNotificationBadgeToBeShowing(fxViewBtn), + "The notification badge is showing in the initial window" + ); + ok( + await waitForNotificationBadgeToBeShowing(fxViewBtn2), + "The notification badge is showing in the second window" + ); + + win2.restore(); + await TestUtils.waitForCondition( + () => win2.gBrowser.selectedBrowser.docShellIsActive, + "Waiting for docshell to be marked as active after restoring the window" + ); + + await BrowserTestUtils.closeWindow(win1); + await BrowserTestUtils.closeWindow(win2); + + sandbox.restore(); +}); + +/** + * Tests the notification badge is in the correct spot and that the badge shows when opening a new window + * if another window is showing the badge + */ +add_task(async function testNotificationDotLocation() { + const sandbox = setupRecentDeviceListMocks(); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + + syncedTabsMock.returns(tabsList1); + + let win1 = await BrowserTestUtils.openNewBrowserWindow(); + let fxViewBtn = win1.document.getElementById("firefox-view-button"); + ok(fxViewBtn, "Got the Firefox View button"); + + // Initiate a synced tabs update + await initTabSync(); + syncedTabsMock.returns(tabsList2); + // Initiate another synced tabs update + await initTabSync(); + + ok( + await waitForNotificationBadgeToBeShowing(fxViewBtn), + "The notification badge is showing initially" + ); + + // Create a new window + let win2 = await BrowserTestUtils.openNewBrowserWindow(); + await win2.delayedStartupPromise; + + // Make sure the badge doesn't showing on the new window + let fxViewBtn2 = win2.document.getElementById("firefox-view-button"); + ok( + await waitForNotificationBadgeToBeShowing(fxViewBtn2), + "The notification badge is showing in the second window after opening" + ); + + // Make sure the badge is below and center now + isnot( + getBackgroundPositionForElement(fxViewBtn), + BADGE_TOP_RIGHT, + "The notification badge is not showing in the top right in the initial window" + ); + isnot( + getBackgroundPositionForElement(fxViewBtn2), + BADGE_TOP_RIGHT, + "The notification badge is not showing in the top right in the second window" + ); + + CustomizableUI.addWidgetToArea( + "firefox-view-button", + CustomizableUI.AREA_NAVBAR + ); + + // Make sure both windows still have the notification badge + ok( + await waitForNotificationBadgeToBeShowing(fxViewBtn), + "The notification badge is showing in the initial window" + ); + ok( + await waitForNotificationBadgeToBeShowing(fxViewBtn2), + "The notification badge is showing in the second window" + ); + + // Make sure the badge is in the top right now + is( + getBackgroundPositionForElement(fxViewBtn), + BADGE_TOP_RIGHT, + "The notification badge is showing in the top right in the initial window" + ); + is( + getBackgroundPositionForElement(fxViewBtn2), + BADGE_TOP_RIGHT, + "The notification badge is showing in the top right in the second window" + ); + + CustomizableUI.reset(); + await BrowserTestUtils.closeWindow(win1); + await BrowserTestUtils.closeWindow(win2); + + sandbox.restore(); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_recently_closed_tabs.js b/browser/components/firefoxview/tests/browser/browser_recently_closed_tabs.js new file mode 100644 index 0000000000..988a576327 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_recently_closed_tabs.js @@ -0,0 +1,798 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * The recently closed tab list is populated on a per-window basis. + * + * By default, the withFirefoxView helper opens a new window. + * When using this helper for the tests in this file, we pass a + * { win: window } option to skip that step and open fx view in + * the current window. This ensures that the add_new_tab, close_tab, + * and open_then_close functions are creating sessionstore entries + * associated with the correct window where the tests are run. + */ + +ChromeUtils.defineESModuleGetters(globalThis, { + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", +}); + +const { TabsSetupFlowManager } = ChromeUtils.importESModule( + "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs" +); + +const RECENTLY_CLOSED_EVENT = [ + ["firefoxview", "entered", "firefoxview", undefined], + ["firefoxview", "recently_closed", "tabs", undefined], +]; + +const CLOSED_TABS_OPEN_EVENT = [ + ["firefoxview", "closed_tabs_open", "tabs", "false"], +]; + +const RECENTLY_CLOSED_DISMISS_EVENT = [ + ["firefoxview", "dismiss_closed_tab", "tabs", undefined], +]; + +async function add_new_tab(URL) { + let tab = BrowserTestUtils.addTab(gBrowser, URL); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + return tab; +} + +async function close_tab(tab) { + const sessionStorePromise = BrowserTestUtils.waitForSessionStoreUpdate(tab); + BrowserTestUtils.removeTab(tab); + await sessionStorePromise; +} + +async function dismiss_tab(tab, content) { + info(`Dismissing tab ${tab.dataset.targetURI}`); + const closedObjectsChanged = () => + TestUtils.topicObserved("sessionstore-closed-objects-changed"); + let dismissButton = tab.querySelector(".closed-tab-li-dismiss"); + EventUtils.synthesizeMouseAtCenter(dismissButton, {}, content); + await closedObjectsChanged(); +} + +add_task(async function test_empty_list() { + clearHistory(); + + await withFirefoxView({ win: window }, async browser => { + const { document } = browser.contentWindow; + let container = document.querySelector("#collapsible-tabs-container"); + ok( + container.classList.contains("empty-container"), + "collapsible container should have correct styling when the list is empty" + ); + + testVisibility(browser, { + expectedVisible: { + "#recently-closed-tabs-placeholder": true, + "ol.closed-tabs-list": false, + }, + }); + + const tab1 = await add_new_tab(URLs[0]); + + await close_tab(tab1); + + // The UI update happens asynchronously as we learn of the new closed tab. + await BrowserTestUtils.waitForMutationCondition( + container, + { attributeFilter: ["class"] }, + () => !container.classList.contains("empty-container") + ); + ok( + !container.classList.contains("empty-container"), + "collapsible container should have correct styling when the list is not empty" + ); + + testVisibility(browser, { + expectedVisible: { + "#recently-closed-tabs-placeholder": false, + "ol.closed-tabs-list": true, + }, + }); + + is( + document.querySelector("ol.closed-tabs-list").children.length, + 1, + "recently-closed-tabs-list should have one list item" + ); + }); +}); + +add_task(async function test_list_ordering() { + Services.obs.notifyObservers(null, "browser:purge-session-history"); + is( + SessionStore.getClosedTabCount(window), + 0, + "Closed tab count after purging session history" + ); + await clearAllParentTelemetryEvents(); + + const closedObjectsChanged = () => + TestUtils.topicObserved("sessionstore-closed-objects-changed"); + + const tab1 = await add_new_tab(URLs[0]); + const tab2 = await add_new_tab(URLs[1]); + const tab3 = await add_new_tab(URLs[2]); + + gBrowser.selectedTab = tab3; + + await close_tab(tab3); + await closedObjectsChanged(); + + await close_tab(tab2); + await closedObjectsChanged(); + + await close_tab(tab1); + await closedObjectsChanged(); + + await withFirefoxView({ win: window }, async browser => { + const { document } = browser.contentWindow; + const tabsList = document.querySelector("ol.closed-tabs-list"); + await BrowserTestUtils.waitForMutationCondition( + tabsList, + { childList: true }, + () => tabsList.children.length > 1 + ); + + is( + document.querySelector("ol.closed-tabs-list").children.length, + 3, + "recently-closed-tabs-list should have three list items" + ); + + // check that the ordering is correct when user navigates to another tab, and then closes multiple tabs. + ok( + document + .querySelector("ol.closed-tabs-list") + .firstChild.textContent.includes("mochi.test"), + "first list item in recently-closed-tabs-list is in the correct order" + ); + + ok( + document + .querySelector("ol.closed-tabs-list") + .children[2].textContent.includes("example.net"), + "last list item in recently-closed-tabs-list is in the correct order" + ); + + let ele = document.querySelector("ol.closed-tabs-list").firstElementChild; + let uri = ele.getAttribute("data-target-u-r-i"); + let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, uri); + ele.click(); + await newTabPromise; + + await TestUtils.waitForCondition( + () => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + return events && events.length >= 2; + }, + "Waiting for entered and recently_closed firefoxview telemetry events.", + 200, + 100 + ); + + TelemetryTestUtils.assertEvents( + RECENTLY_CLOSED_EVENT, + { category: "firefoxview" }, + { clear: true, process: "parent" } + ); + + gBrowser.removeTab(gBrowser.selectedTab); + + await clearAllParentTelemetryEvents(); + + await waitForElementVisible( + browser, + "#recently-closed-tabs-container > summary" + ); + document.querySelector("#recently-closed-tabs-container > summary").click(); + + await TestUtils.waitForCondition( + () => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + return events && events.length >= 1; + }, + "Waiting for closed_tabs_open firefoxview telemetry event.", + 200, + 100 + ); + + TelemetryTestUtils.assertEvents( + CLOSED_TABS_OPEN_EVENT, + { category: "firefoxview" }, + { clear: true, process: "parent" } + ); + }); +}); + +add_task(async function test_max_list_items() { + Services.obs.notifyObservers(null, "browser:purge-session-history"); + is( + SessionStore.getClosedTabCount(window), + 0, + "Closed tab count after purging session history" + ); + + await open_then_close(URLs[0]); + await open_then_close(URLs[1]); + await open_then_close(URLs[2]); + + // Seed the closed tabs count. We've assured that we've opened and + // closed at least three tabs because of the calls to open_then_close + // above. + let mockMaxTabsLength = 3; + + await withFirefoxView({ win: window }, async browser => { + const { document } = browser.contentWindow; + + // override this value for testing purposes + document.querySelector( + "recently-closed-tabs-list" + ).maxTabsLength = mockMaxTabsLength; + + ok( + !document + .querySelector("#collapsible-tabs-container") + .classList.contains("empty-container"), + "collapsible container should have correct styling when the list is not empty" + ); + + testVisibility(browser, { + expectedVisible: { + "#recently-closed-tabs-placeholder": false, + "ol.closed-tabs-list": true, + }, + }); + + is( + document.querySelector("ol.closed-tabs-list").childNodes.length, + mockMaxTabsLength, + `recently-closed-tabs-list should have ${mockMaxTabsLength} list items` + ); + + const closedObjectsChanged = TestUtils.topicObserved( + "sessionstore-closed-objects-changed" + ); + // add another tab + const tab = await add_new_tab(URLs[3]); + await close_tab(tab); + await closedObjectsChanged; + + let firstListItem = document.querySelector("ol.closed-tabs-list") + .firstChild; + await BrowserTestUtils.waitForMutationCondition( + firstListItem, + { characterData: true, childList: true, subtree: true }, + () => firstListItem.textContent.includes(".org") + ); + ok( + firstListItem.textContent.includes("example.org"), + "first list item in recently-closed-tabs-list should have been updated" + ); + + is( + document.querySelector("ol.closed-tabs-list").childNodes.length, + mockMaxTabsLength, + `recently-closed-tabs-list should still have ${mockMaxTabsLength} list items` + ); + }); +}); + +add_task(async function test_time_updates_correctly() { + clearHistory(); + is( + SessionStore.getClosedTabCount(window), + 0, + "Closed tab count after purging session history" + ); + + // Set the closed tabs state to include one tab that was closed 2 seconds ago. + // This is well below the initial threshold for displaying the 'Just now' timestamp. + // It is also much greater than the 5ms threshold we use for the updated pref value, + // which results in the timestamp text changing after the pref value is changed. + const TAB_CLOSED_AGO_MS = 2000; + const TAB_UPDATE_TIME_MS = 5; + const TAB_CLOSED_STATE = { + windows: [ + { + tabs: [{ entries: [] }], + _closedTabs: [ + { + state: { entries: [{ url: "https://www.example.com/" }] }, + closedId: 0, + closedAt: Date.now() - TAB_CLOSED_AGO_MS, + image: null, + }, + ], + }, + ], + }; + await SessionStore.setBrowserState(JSON.stringify(TAB_CLOSED_STATE)); + + is( + SessionStore.getClosedTabCount(window), + 1, + "Closed tab count after setting browser state" + ); + + await withFirefoxView( + { + win: window, + }, + async browser => { + const { document } = browser.contentWindow; + + const lastListItem = document.querySelector("ol.closed-tabs-list") + .lastChild; + const timeLabel = lastListItem.querySelector("span.closed-tab-li-time"); + let initialTimeText = timeLabel.textContent; + Assert.stringContains( + initialTimeText, + "Just now", + "recently-closed-tabs list item time is 'Just now'" + ); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.tabs.firefox-view.updateTimeMs", TAB_UPDATE_TIME_MS]], + }); + + await BrowserTestUtils.waitForMutationCondition( + timeLabel, + { childList: true }, + () => !timeLabel.textContent.includes("now") + ); + + isnot( + timeLabel.textContent, + initialTimeText, + "recently-closed-tabs list item time has updated" + ); + + await SpecialPowers.popPrefEnv(); + } + ); + // Cleanup recently closed tab data. + clearHistory(); +}); + +add_task(async function test_list_maintains_focus_when_restoring_tab() { + await SpecialPowers.clearUserPref(RECENTLY_CLOSED_STATE_PREF); + Services.obs.notifyObservers(null, "browser:purge-session-history"); + is( + SessionStore.getClosedTabCount(window), + 0, + "Closed tab count after purging session history" + ); + + const sandbox = sinon.createSandbox(); + let setupCompleteStub = sandbox.stub( + TabsSetupFlowManager, + "isTabSyncSetupComplete" + ); + setupCompleteStub.returns(true); + + await open_then_close(URLs[0]); + await open_then_close(URLs[1]); + await open_then_close(URLs[2]); + + await withFirefoxView({ win: window }, async browser => { + let gBrowser = browser.getTabBrowser(); + const { document } = browser.contentWindow; + const list = document.querySelectorAll(".closed-tab-li"); + let expectedFocusedElement = list[1].querySelector(".closed-tab-li-main"); + list[0].querySelector(".closed-tab-li-main").focus(); + EventUtils.synthesizeKey("KEY_Enter"); + let firefoxViewTab = gBrowser.tabs.find(tab => tab.label == "Firefox View"); + await BrowserTestUtils.switchTab(gBrowser, firefoxViewTab); + is( + document.activeElement, + expectedFocusedElement, + "Focus should be on the first item in the recently closed list" + ); + }); + + // clean up extra tabs + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.tabs.at(-1)); + } + + clearHistory(); + await open_then_close(URLs[2]); + await withFirefoxView({ win: window }, async browser => { + let gBrowser = browser.getTabBrowser(); + const { document } = browser.contentWindow; + let expectedFocusedElement = document.getElementById( + "recently-closed-tabs-header-section" + ); + const list = document.querySelectorAll(".closed-tab-li"); + list[0].querySelector(".closed-tab-li-main").focus(); + + EventUtils.synthesizeKey("KEY_Enter"); + let firefoxViewTab = gBrowser.tabs.find(tab => tab.label == "Firefox View"); + await BrowserTestUtils.switchTab(gBrowser, firefoxViewTab); + is( + document.activeElement, + expectedFocusedElement, + "Focus should be on the section header" + ); + }); + + // clean up extra tabs + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.tabs.at(-1)); + } +}); + +add_task(async function test_switch_before_closing() { + clearHistory(); + + const INITIAL_URL = "https://example.org/iwilldisappear"; + const FINAL_URL = "https://example.com/ishouldappear"; + await withFirefoxView({ win: window }, async function(browser) { + let gBrowser = browser.getTabBrowser(); + let newTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + INITIAL_URL + ); + // Switch back to FxView: + await BrowserTestUtils.switchTab( + gBrowser, + gBrowser.getTabForBrowser(browser) + ); + // Update the tab we opened to a different site: + let loadPromise = BrowserTestUtils.browserLoaded( + newTab.linkedBrowser, + null, + FINAL_URL + ); + BrowserTestUtils.loadURI(newTab.linkedBrowser, FINAL_URL); + await loadPromise; + + // Close the added tab + BrowserTestUtils.removeTab(newTab); + + const { document } = browser.contentWindow; + const tabsList = document.querySelector("ol.closed-tabs-list"); + await BrowserTestUtils.waitForMutationCondition( + tabsList, + { childList: true }, + () => !!tabsList.children.length + ); + info("A tab appeared in the list, ensure it has the right URL."); + let urlBit = tabsList.firstElementChild.querySelector(".closed-tab-li-url"); + await BrowserTestUtils.waitForMutationCondition( + urlBit, + { characterData: true, attributeFilter: ["title"] }, + () => urlBit.textContent.includes(".com") + ); + is( + urlBit.textContent, + "example.com", + "Item should end up with the correct URL." + ); + }); +}); + +add_task(async function test_alt_click_no_launch() { + Services.obs.notifyObservers(null, "browser:purge-session-history"); + is( + SessionStore.getClosedTabCount(window), + 0, + "Closed tab count after purging session history" + ); + + await open_then_close(URLs[0]); + + await withFirefoxView({ win: window }, async browser => { + let gBrowser = browser.getTabBrowser(); + let originalTabsLength = gBrowser.tabs.length; + await BrowserTestUtils.synthesizeMouseAtCenter( + ".closed-tab-li", + { altKey: true }, + browser + ); + + is( + gBrowser.tabs.length, + originalTabsLength, + `Opened tabs length should still be ${originalTabsLength}` + ); + }); +}); + +/** + * Asserts that tabs that have been recently closed can be + * restored by clicking on them, using the Enter key, + * and using the Space bar. + */ +add_task(async function test_restore_recently_closed_tabs() { + clearHistory(); + + await open_then_close(URLs[0]); + await open_then_close(URLs[1]); + await open_then_close(URLs[2]); + + await EventUtils.synthesizeMouseAtCenter( + gBrowser.ownerDocument.getElementById("firefox-view-button"), + { type: "mousedown" }, + window + ); + // Wait for Firefox View to be loaded before interacting + // with the page. + await BrowserTestUtils.browserLoaded( + window.FirefoxViewHandler.tab.linkedBrowser + ); + let { document } = gBrowser.contentWindow; + let tabRestored = BrowserTestUtils.waitForNewTab(gBrowser, URLs[2]); + EventUtils.synthesizeMouseAtCenter( + document.querySelector(".closed-tab-li"), + {}, + gBrowser.contentWindow + ); + + await tabRestored; + ok(true, "Tab was restored by mouse click"); + + await EventUtils.synthesizeMouseAtCenter( + gBrowser.ownerDocument.getElementById("firefox-view-button"), + { type: "mousedown" }, + window + ); + + tabRestored = BrowserTestUtils.waitForNewTab(gBrowser, URLs[1]); + document.querySelector(".closed-tab-li .closed-tab-li-main").focus(); + EventUtils.synthesizeKey("KEY_Enter", {}, gBrowser.contentWindow); + + await tabRestored; + ok(true, "Tab was restored by using the Enter key"); + + await EventUtils.synthesizeMouseAtCenter( + gBrowser.ownerDocument.getElementById("firefox-view-button"), + { type: "mousedown" }, + window + ); + + tabRestored = BrowserTestUtils.waitForNewTab(gBrowser, URLs[0]); + document.querySelector(".closed-tab-li .closed-tab-li-main").focus(); + EventUtils.synthesizeKey(" ", {}, gBrowser.contentWindow); + + await tabRestored; + ok(true, "Tab was restored by using the Space bar"); + + // clean up extra tabs + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.tabs.at(-1)); + } +}); + +/** + * Asserts that tabs are removed from Recently Closed tabs in + * Fx View when tabs are removed from latest closed tab data. + * Ex: Selecting "Reopen Closed Tab" from the tabs toolbar + * context menu + */ +add_task(async function test_reopen_recently_closed_tabs() { + clearHistory(); + + await open_then_close(URLs[0]); + await open_then_close(URLs[1]); + await open_then_close(URLs[2]); + + await EventUtils.synthesizeMouseAtCenter( + gBrowser.ownerDocument.getElementById("firefox-view-button"), + { type: "mousedown" }, + window + ); + // Wait for Firefox View to be loaded before interacting + // with the page. + await BrowserTestUtils.browserLoaded( + window.FirefoxViewHandler.tab.linkedBrowser + ); + + let { document } = gBrowser.contentWindow; + + let tabReopened = BrowserTestUtils.waitForNewTab(gBrowser, URLs[2]); + SessionStore.undoCloseTab(window); + await tabReopened; + + const tabsList = document.querySelector("ol.closed-tabs-list"); + + await EventUtils.synthesizeMouseAtCenter( + gBrowser.ownerDocument.getElementById("firefox-view-button"), + { type: "mousedown" }, + window + ); + + await BrowserTestUtils.waitForMutationCondition( + tabsList, + { childList: true }, + () => tabsList.children.length === 2 + ); + + Assert.equal( + tabsList.children[0].dataset.targetURI, + URLs[1], + `First recently closed item should be ${URLs[1]}` + ); + + await close_tab(gBrowser.visibleTabs[gBrowser.visibleTabs.length - 1]); + + await BrowserTestUtils.waitForMutationCondition( + tabsList, + { childList: true }, + () => tabsList.children.length === 3 + ); + + Assert.equal( + tabsList.children[0].dataset.targetURI, + URLs[2], + `First recently closed item should be ${URLs[2]}` + ); + + await dismiss_tab(tabsList.children[0], content); + + await BrowserTestUtils.waitForMutationCondition( + tabsList, + { childList: true }, + () => tabsList.children.length === 2 + ); + + Assert.equal( + tabsList.children[0].dataset.targetURI, + URLs[1], + `First recently closed item should be ${URLs[1]}` + ); + + // clean up extra tabs + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.tabs.at(-1)); + } +}); + +/** + * Asserts that tabs that have been recently closed can be + * dismissed by clicking on their respective dismiss buttons. + */ +add_task(async function test_dismiss_tab() { + Services.obs.notifyObservers(null, "browser:purge-session-history"); + Assert.equal( + SessionStore.getClosedTabCount(window), + 0, + "Closed tab count after purging session history" + ); + await clearAllParentTelemetryEvents(); + + await withFirefoxView({ win: window }, async browser => { + const { document } = browser.contentWindow; + + const closedObjectsChanged = () => + TestUtils.topicObserved("sessionstore-closed-objects-changed"); + + const tab1 = await add_new_tab(URLs[0]); + const tab2 = await add_new_tab(URLs[1]); + const tab3 = await add_new_tab(URLs[2]); + + await close_tab(tab3); + await closedObjectsChanged(); + + await close_tab(tab2); + await closedObjectsChanged(); + + await close_tab(tab1); + await closedObjectsChanged(); + + const tabsList = document.querySelector("ol.closed-tabs-list"); + + await clearAllParentTelemetryEvents(); + + await dismiss_tab(tabsList.children[0], content); + + await TestUtils.waitForCondition( + () => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + return events && events.length >= 1; + }, + "Waiting for dismiss_closed_tab firefoxview telemetry event.", + 200, + 100 + ); + + TelemetryTestUtils.assertEvents( + RECENTLY_CLOSED_DISMISS_EVENT, + { category: "firefoxview" }, + { clear: true, process: "parent" } + ); + + Assert.equal( + tabsList.children[0].dataset.targetURI, + URLs[1], + `First recently closed item should be ${URLs[1]}` + ); + + Assert.equal( + tabsList.children.length, + 2, + "recently-closed-tabs-list should have two list items" + ); + + await clearAllParentTelemetryEvents(); + + await dismiss_tab(tabsList.children[0], content); + + await TestUtils.waitForCondition( + () => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + return events && events.length >= 1; + }, + "Waiting for dismiss_closed_tab firefoxview telemetry event.", + 200, + 100 + ); + + TelemetryTestUtils.assertEvents( + RECENTLY_CLOSED_DISMISS_EVENT, + { category: "firefoxview" }, + { clear: true, process: "parent" } + ); + + Assert.equal( + tabsList.children[0].dataset.targetURI, + URLs[2], + `First recently closed item should be ${URLs[2]}` + ); + + Assert.equal( + tabsList.children.length, + 1, + "recently-closed-tabs-list should have one list item" + ); + + await clearAllParentTelemetryEvents(); + + await dismiss_tab(tabsList.children[0], content); + + await TestUtils.waitForCondition( + () => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + return events && events.length >= 1; + }, + "Waiting for dismiss_closed_tab firefoxview telemetry event.", + 200, + 100 + ); + + TelemetryTestUtils.assertEvents( + RECENTLY_CLOSED_DISMISS_EVENT, + { category: "firefoxview" }, + { clear: true, process: "parent" } + ); + + testVisibility(browser, { + expectedVisible: { + "#recently-closed-tabs-placeholder": true, + "ol.closed-tabs-list": false, + }, + }); + }); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_recently_closed_tabs_keyboard.js b/browser/components/firefoxview/tests/browser/browser_recently_closed_tabs_keyboard.js new file mode 100644 index 0000000000..2d495934df --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_recently_closed_tabs_keyboard.js @@ -0,0 +1,255 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +async function dismiss_tab_keyboard(closedTab, document) { + const enter = () => { + info("Enter"); + EventUtils.synthesizeKey("KEY_Enter"); + }; + const tab = (shiftKey = false) => { + info(`${shiftKey ? "Shift + Tab" : "Tab"}`); + EventUtils.synthesizeKey("KEY_Tab", { shiftKey }); + }; + const closedObjectsChanged = () => + TestUtils.topicObserved("sessionstore-closed-objects-changed"); + let firstTabMainContent = closedTab.querySelector(".closed-tab-li-main"); + let dismissButton = closedTab.querySelector(".closed-tab-li-dismiss"); + firstTabMainContent.focus(); + tab(); + Assert.equal( + document.activeElement, + dismissButton, + "Focus should be on the dismiss button for the first item in the recently closed list" + ); + enter(); + await closedObjectsChanged(); +} + +/** + * Tests keyboard navigation of the recently closed tabs component + */ +add_task(async function test_keyboard_navigation() { + const enter = () => { + info("Enter"); + EventUtils.synthesizeKey("KEY_Enter"); + }; + const tab = (shiftKey = false) => { + info(`${shiftKey ? "Shift + Tab" : "Tab"}`); + EventUtils.synthesizeKey("KEY_Tab", { shiftKey }); + }; + /** + * Focus the summary element and asserts that: + * - The recently closed details should be initially opened + * - The recently closed details can be opened and closed via the Enter key + * + * @param {Document} document The currently used browser's content window document + * @param {HTMLElement} summary The header section element for recently closed tabs + */ + const assertPreconditions = (document, summary) => { + let details = document.getElementById("recently-closed-tabs-container"); + ok( + details.open, + "Recently closed details should be initially open on load" + ); + summary.focus(); + enter(); + ok(!details.open, "Recently closed details should be closed"); + enter(); + ok(details.open, "Recently closed details should be opened"); + }; + await SpecialPowers.clearUserPref(RECENTLY_CLOSED_STATE_PREF); + Services.obs.notifyObservers(null, "browser:purge-session-history"); + is( + SessionStore.getClosedTabCount(window), + 0, + "Closed tab count after purging session history" + ); + + const sandbox = sinon.createSandbox(); + let setupCompleteStub = sandbox.stub( + TabsSetupFlowManager, + "isTabSyncSetupComplete" + ); + setupCompleteStub.returns(true); + + await open_then_close(URLs[0]); + + await withFirefoxView({ win: window }, async browser => { + const { document } = browser.contentWindow; + const list = document.querySelectorAll(".closed-tab-li"); + let summary = document.getElementById( + "recently-closed-tabs-header-section" + ); + + assertPreconditions(document, summary); + tab(); + + ok( + list[0].querySelector(".closed-tab-li-main").matches(":focus"), + "The first link is focused" + ); + + tab(true); + ok( + summary.matches(":focus"), + "The container is focused when using shift+tab in the list" + ); + }); + // clean up extra tabs + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.tabs.at(-1)); + } + + clearHistory(); + + await open_then_close(URLs[0]); + await open_then_close(URLs[1]); + + await withFirefoxView({ win: window }, async browser => { + const { document } = browser.contentWindow; + const list = document.querySelectorAll(".closed-tab-li"); + let summary = document.getElementById( + "recently-closed-tabs-header-section" + ); + assertPreconditions(document, summary); + + tab(); + + ok( + list[0].querySelector(".closed-tab-li-main").matches(":focus"), + "The first link is focused" + ); + tab(); + tab(); + ok( + list[1].querySelector(".closed-tab-li-main").matches(":focus"), + "The second link is focused" + ); + tab(true); + tab(true); + ok( + list[0].querySelector(".closed-tab-li-main").matches(":focus"), + "The first link is focused again" + ); + + tab(true); + ok( + summary.matches(":focus"), + "The container is focused when using shift+tab in the list" + ); + }); + + // clean up extra tabs + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.tabs.at(-1)); + } + + clearHistory(); + + await open_then_close(URLs[0]); + await open_then_close(URLs[1]); + await open_then_close(URLs[2]); + + await withFirefoxView({ win: window }, async browser => { + const { document } = browser.contentWindow; + const list = document.querySelectorAll(".closed-tab-li"); + let summary = document.getElementById( + "recently-closed-tabs-header-section" + ); + assertPreconditions(document, summary); + + tab(); + + ok( + list[0].querySelector(".closed-tab-li-main").matches(":focus"), + "The first link is focused" + ); + tab(); + tab(); + ok( + list[1].querySelector(".closed-tab-li-main").matches(":focus"), + "The second link is focused" + ); + tab(); + tab(); + ok( + list[2].querySelector(".closed-tab-li-main").matches(":focus"), + "The third link is focused" + ); + tab(true); + tab(true); + ok( + list[1].querySelector(".closed-tab-li-main").matches(":focus"), + "The second link is focused" + ); + tab(true); + tab(true); + ok( + list[0].querySelector(".closed-tab-li-main").matches(":focus"), + "The first link is focused" + ); + }); +}); + +add_task(async function test_dismiss_tab_keyboard() { + Services.obs.notifyObservers(null, "browser:purge-session-history"); + Assert.equal( + SessionStore.getClosedTabCount(window), + 0, + "Closed tab count after purging session history" + ); + await withFirefoxView({ win: window }, async browser => { + const { document } = browser.contentWindow; + + await open_then_close(URLs[0]); + await open_then_close(URLs[1]); + await open_then_close(URLs[2]); + + await EventUtils.synthesizeMouseAtCenter( + gBrowser.ownerDocument.getElementById("firefox-view-button"), + { type: "mousedown" }, + window + ); + + const tabsList = document.querySelector("ol.closed-tabs-list"); + + await dismiss_tab_keyboard(tabsList.children[0], document); + + Assert.equal( + tabsList.children[0].dataset.targetURI, + URLs[1], + `First recently closed item should be ${URLs[1]}` + ); + + Assert.equal( + tabsList.children.length, + 2, + "recently-closed-tabs-list should have two list items" + ); + + await dismiss_tab_keyboard(tabsList.children[0], document); + + Assert.equal( + tabsList.children[0].dataset.targetURI, + URLs[0], + `First recently closed item should be ${URLs[0]}` + ); + + Assert.equal( + tabsList.children.length, + 1, + "recently-closed-tabs-list should have one list item" + ); + + await dismiss_tab_keyboard(tabsList.children[0], document); + + testVisibility(browser, { + expectedVisible: { + "#recently-closed-tabs-placeholder": true, + "ol.closed-tabs-list": false, + }, + }); + }); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_reload_firefoxview.js b/browser/components/firefoxview/tests/browser/browser_reload_firefoxview.js new file mode 100644 index 0000000000..f9a226bbf2 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_reload_firefoxview.js @@ -0,0 +1,36 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* + Ensures that the Firefox View tab can be reloaded via: + - Clicking the Refresh button in the toolbar + - Using the various keyboard shortcuts +*/ +add_task(async function test_reload_firefoxview() { + await withFirefoxView({}, async browser => { + let reloadButton = document.getElementById("reload-button"); + let tabLoaded = BrowserTestUtils.browserLoaded(browser); + EventUtils.synthesizeMouseAtCenter(reloadButton, {}, browser.ownerGlobal); + await tabLoaded; + ok(true, "Firefox View loaded after clicking the Reload button"); + + let keys = [ + ["R", { accelKey: true }], + ["R", { accelKey: true, shift: true }], + ["VK_F5", {}], + ]; + + if (AppConstants.platform != "macosx") { + keys.push(["VK_F5", { accelKey: true }]); + } + + for (let key of keys) { + tabLoaded = BrowserTestUtils.browserLoaded(browser); + EventUtils.synthesizeKey(key[0], key[1], browser.ownerGlobal); + await tabLoaded; + ok(true, `Firefox view loaded after using ${key}`); + } + }); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_setup_errors.js b/browser/components/firefoxview/tests/browser/browser_setup_errors.js new file mode 100644 index 0000000000..4929e93600 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_setup_errors.js @@ -0,0 +1,374 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { TabsSetupFlowManager } = ChromeUtils.importESModule( + "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs" +); + +const { LoginTestUtils } = ChromeUtils.import( + "resource://testing-common/LoginTestUtils.jsm" +); + +async function setupWithDesktopDevices(state = UIState.STATUS_SIGNED_IN) { + const sandbox = setupSyncFxAMocks({ + state, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + }, + { + id: 2, + name: "Other Device", + type: "desktop", + }, + ], + }); + return sandbox; +} + +async function tearDown(sandbox) { + sandbox?.restore(); + Services.prefs.clearUserPref("services.sync.lastTabFetch"); + Services.prefs.clearUserPref("identity.fxaccounts.enabled"); +} + +add_setup(async function() { + // gSync.init() is called in a requestIdleCallback. Force its initialization. + gSync.init(); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["services.sync.engine.tabs", true], + ["identity.fxaccounts.enabled", true], + ], + }); + + registerCleanupFunction(async function() { + // reset internal state so it doesn't affect the next tests + TabsSetupFlowManager.resetInternalState(); + await tearDown(gSandbox); + }); +}); + +add_task(async function test_network_offline() { + const sandbox = await setupWithDesktopDevices(); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + Services.obs.notifyObservers( + null, + "network:offline-status-changed", + "offline" + ); + await waitForElementVisible(browser, "#tabpickup-steps", true); + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view0", + }); + + const errorStateHeader = document.querySelector( + "#tabpickup-steps-view0-header" + ); + + await BrowserTestUtils.waitForMutationCondition( + errorStateHeader, + { childList: true }, + () => errorStateHeader.textContent.includes("connection") + ); + + ok( + errorStateHeader.getAttribute("data-l10n-id").includes("network-offline"), + "Correct message should show when network connection is lost" + ); + + Services.obs.notifyObservers( + null, + "network:offline-status-changed", + "online" + ); + + await waitForElementVisible(browser, "#tabpickup-tabs-container", true); + }); + await tearDown(sandbox); +}); + +add_task(async function test_sync_error() { + const sandbox = await setupWithDesktopDevices(); + sandbox.spy(TabsSetupFlowManager, "tryToClearError"); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + Services.obs.notifyObservers(null, "weave:service:sync:error"); + + await waitForElementVisible(browser, "#tabpickup-steps", true); + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view0", + }); + + const errorStateHeader = document.querySelector( + "#tabpickup-steps-view0-header" + ); + + await BrowserTestUtils.waitForMutationCondition( + errorStateHeader, + { childList: true }, + () => errorStateHeader.textContent.includes("trouble syncing") + ); + + ok( + errorStateHeader.getAttribute("data-l10n-id").includes("sync-error"), + "Correct message should show when there's a sync service error" + ); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "#error-state-button", + {}, + browser + ); + + await BrowserTestUtils.waitForCondition(() => { + return TabsSetupFlowManager.tryToClearError.calledOnce; + }); + + ok( + TabsSetupFlowManager.tryToClearError.calledOnce, + "TabsSetupFlowManager.tryToClearError() was called once" + ); + + // Clear the error. + Services.obs.notifyObservers(null, "weave:service:sync:finish"); + }); + + // Now reopen the tab and check that sending an error state does not + // start showing the error: + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + const recentFetchTime = Math.floor(Date.now() / 1000); + info("updating lastFetch:" + recentFetchTime); + Services.prefs.setIntPref("services.sync.lastTabFetch", recentFetchTime); + await withFirefoxView({ resetFlowManager: false }, async browser => { + const { document } = browser.contentWindow; + + await waitForElementVisible(browser, "#synced-tabs-placeholder", true); + + Services.obs.notifyObservers(null, "weave:service:sync:error"); + await TestUtils.waitForTick(); + ok( + BrowserTestUtils.is_visible( + document.getElementById("synced-tabs-placeholder") + ), + "Should still be showing the placeholder content." + ); + let stepHeader = document.getElementById("tabpickup-steps-view0-header"); + ok( + !stepHeader || BrowserTestUtils.is_hidden(stepHeader), + "Should not be showing an error state if we had previously synced successfully." + ); + + // Now drop a device: + let someDevice = gMockFxaDevices.pop(); + Services.obs.notifyObservers(null, "fxaccounts:devicelist_updated"); + // This will trip a UI update where we decide we can't rely on + // previously synced tabs anymore (they may be from the device + // that was removed!), so we still show an error: + + await waitForElementVisible(browser, "#tabpickup-steps", true); + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view0", + }); + + const errorStateHeader = document.querySelector( + "#tabpickup-steps-view0-header" + ); + + await BrowserTestUtils.waitForMutationCondition( + errorStateHeader, + { childList: true }, + () => errorStateHeader.textContent.includes("trouble syncing") + ); + + ok( + errorStateHeader.getAttribute("data-l10n-id").includes("sync-error"), + "Correct message should show when there's an error and tab information is outdated." + ); + + // Sneak device back in so as not to break other tests: + gMockFxaDevices.push(someDevice); + // Clear the error. + Services.obs.notifyObservers(null, "weave:service:sync:finish"); + }); + Services.prefs.clearUserPref("services.sync.lastTabFetch"); + + await tearDown(sandbox); +}); + +add_task(async function test_sync_error_signed_out() { + // sync error should not show if user is not signed in + let sandbox = await setupWithDesktopDevices(UIState.STATUS_NOT_CONFIGURED); + await withFirefoxView({}, async browser => { + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + Services.obs.notifyObservers(null, "weave:service:sync:error"); + + await waitForElementVisible(browser, "#tabpickup-steps", true); + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view1", + }); + }); + await tearDown(sandbox); +}); + +add_task(async function test_sync_disconnected_error() { + // it's possible for fxa to be enabled but sync not enabled. + const sandbox = setupSyncFxAMocks({ + state: UIState.STATUS_SIGNED_IN, + syncEnabled: false, + }); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + // triggered when user disconnects sync in about:preferences + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + await waitForElementVisible(browser, "#tabpickup-steps", true); + info("Waiting for the tabpickup error step to be visible"); + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view0", + }); + + const errorStateHeader = document.querySelector( + "#tabpickup-steps-view0-header" + ); + + info( + "Waiting for a mutation condition to ensure the right syncing error message" + ); + await BrowserTestUtils.waitForMutationCondition( + errorStateHeader, + { childList: true }, + () => errorStateHeader.textContent.includes("Turn on syncing to continue") + ); + + ok( + errorStateHeader + .getAttribute("data-l10n-id") + .includes("sync-disconnected"), + "Correct message should show when sync's been disconnected error" + ); + + let preferencesTabPromise = BrowserTestUtils.waitForNewTab( + browser.getTabBrowser(), + "about:preferences?action=choose-what-to-sync#sync", + true + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#error-state-button", + {}, + browser + ); + let preferencesTab = await preferencesTabPromise; + await BrowserTestUtils.removeTab(preferencesTab); + }); + await tearDown(sandbox); +}); + +add_task(async function test_password_change_disconnect_error() { + // When the user changes their password on another device, we get into a state + // where the user is signed out but sync is still enabled. + const sandbox = setupSyncFxAMocks({ + state: UIState.STATUS_LOGIN_FAILED, + syncEnabled: true, + }); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + // triggered by the user changing fxa password on another device + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + await waitForElementVisible(browser, "#tabpickup-steps", true); + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view0", + }); + + const errorStateHeader = document.querySelector( + "#tabpickup-steps-view0-header" + ); + + await BrowserTestUtils.waitForMutationCondition( + errorStateHeader, + { childList: true }, + () => errorStateHeader.textContent.includes("Sign in to reconnect") + ); + + ok( + errorStateHeader.getAttribute("data-l10n-id").includes("signed-out"), + "Correct message should show when user has been logged out due to external password change." + ); + }); + await tearDown(sandbox); +}); + +add_task(async function test_multiple_errors() { + let sandbox = await setupWithDesktopDevices(); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + // Simulate conditions in which both the locked password and sync error + // messages could be shown + LoginTestUtils.primaryPassword.enable(); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + Services.obs.notifyObservers(null, "weave:service:sync:error"); + + info("Waiting for the primary password error message to be shown"); + await waitForElementVisible(browser, "#tabpickup-steps", true); + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view0", + }); + + const errorStateHeader = document.querySelector( + "#tabpickup-steps-view0-header" + ); + await BrowserTestUtils.waitForMutationCondition( + errorStateHeader, + { childList: true }, + () => errorStateHeader.textContent.includes("Enter your Primary Password") + ); + + ok( + errorStateHeader.getAttribute("data-l10n-id").includes("password-locked"), + "Password locked error message is shown" + ); + + const errorLink = document.querySelector("#error-state-link"); + ok( + errorLink && BrowserTestUtils.is_visible(errorLink), + "Error link is visible" + ); + + // Clear the primary password error message + LoginTestUtils.primaryPassword.disable(); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + info("Waiting for the sync error message to be shown"); + await BrowserTestUtils.waitForMutationCondition( + errorStateHeader, + { childList: true }, + () => errorStateHeader.textContent.includes("trouble syncing") + ); + + ok( + errorStateHeader.getAttribute("data-l10n-id").includes("sync-error"), + "Sync error message is now shown" + ); + + ok( + errorLink && BrowserTestUtils.is_hidden(errorLink), + "Error link is now hidden" + ); + + // Clear the sync error + Services.obs.notifyObservers(null, "weave:service:sync:finish"); + }); + await tearDown(sandbox); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_setup_primary_password.js b/browser/components/firefoxview/tests/browser/browser_setup_primary_password.js new file mode 100644 index 0000000000..655ecf1e6f --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_setup_primary_password.js @@ -0,0 +1,150 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { TabsSetupFlowManager } = ChromeUtils.importESModule( + "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs" +); + +const { LoginTestUtils } = ChromeUtils.import( + "resource://testing-common/LoginTestUtils.jsm" +); + +async function tearDown(sandbox) { + sandbox?.restore(); + Services.prefs.clearUserPref("services.sync.lastTabFetch"); +} + +function setupMocks() { + const sandbox = (gSandbox = setupRecentDeviceListMocks()); + return sandbox; +} + +add_setup(async function() { + registerCleanupFunction(async () => { + // reset internal state so it doesn't affect the next tests + TabsSetupFlowManager.resetInternalState(); + LoginTestUtils.primaryPassword.disable(); + await tearDown(gSandbox); + }); + await SpecialPowers.pushPrefEnv({ + set: [["services.sync.username", "username@example.com"]], + }); +}); + +add_task(async function test_primary_password_locked() { + LoginTestUtils.primaryPassword.enable(); + const sandbox = setupMocks(); + + await withFirefoxView({}, async browser => { + sandbox + .stub(TabsSetupFlowManager, "syncTabs") + .returns(Promise.resolve(null)); + sandbox.stub(TabsSetupFlowManager, "startFullTabsSync").returns(undefined); + + const { document } = browser.contentWindow; + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + info("waiting for the error setup step to be visible"); + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view0", + }); + + const errorStateHeader = document.querySelector( + "#tabpickup-steps-view0-header" + ); + await BrowserTestUtils.waitForMutationCondition( + errorStateHeader, + { childList: true }, + () => errorStateHeader.textContent.includes("Enter your Primary Password") + ); + + ok( + errorStateHeader.getAttribute("data-l10n-id").includes("password-locked"), + "Correct error message is shown" + ); + + const errorLink = document.querySelector("#error-state-link"); + ok( + errorLink && BrowserTestUtils.is_visible(errorLink), + "Error link is visible" + ); + ok( + errorLink.getAttribute("data-l10n-id").includes("password-locked-link"), + "Correct link text is shown" + ); + + const primaryButton = document.querySelector("#error-state-button"); + ok( + primaryButton && BrowserTestUtils.is_visible(primaryButton), + "Error primary button is visible" + ); + + const clearErrorStub = sandbox.stub( + TabsSetupFlowManager, + "tryToClearError" + ); + info("Setup state:" + TabsSetupFlowManager.currentSetupState.name); + + info("clicking the error panel button"); + primaryButton.click(); + ok( + clearErrorStub.called, + "tryToClearError was called when the try-again button was clicked" + ); + TabsSetupFlowManager.tryToClearError.restore(); + + info("Clearing the primary password"); + LoginTestUtils.primaryPassword.disable(); + ok( + !TabsSetupFlowManager.isPrimaryPasswordLocked, + "primary password is unlocked" + ); + + info("notifying of the primary-password unlock"); + const clearErrorSpy = sandbox.spy(TabsSetupFlowManager, "tryToClearError"); + // we stubbed out sync, so pretend it ran. + info("notifying of sync:finish"); + Services.obs.notifyObservers(null, "weave:service:sync:finish"); + + const setupContainer = document.querySelector(".sync-setup-container"); + // wait until the setup container gets hidden before checking if the tabs container is visible + // as it may not exist until then + let setupHiddenPromise = BrowserTestUtils.waitForMutationCondition( + setupContainer, + { + attributeFilter: ["hidden"], + }, + () => { + return BrowserTestUtils.is_hidden(setupContainer); + } + ); + + Services.obs.notifyObservers(null, "passwordmgr-crypto-login"); + await setupHiddenPromise; + ok( + clearErrorSpy.called, + "tryToClearError was called when the primary-password unlock notification was received" + ); + // We expect the waiting state until we get a sync update/finished + info("Setup state:" + TabsSetupFlowManager.currentSetupState.name); + + ok(TabsSetupFlowManager.waitingForTabs, "Now waiting for tabs"); + ok( + document + .querySelector("#tabpickup-tabs-container") + .classList.contains("loading"), + "Synced tabs container has loading class" + ); + + info("notifying of sync:finish"); + Services.obs.notifyObservers(null, "weave:service:sync:finish"); + await TestUtils.waitForTick(); + ok( + !document + .querySelector("#tabpickup-tabs-container") + .classList.contains("loading"), + "Synced tabs isn't loading any more" + ); + }); + await tearDown(sandbox); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_setup_state.js b/browser/components/firefoxview/tests/browser/browser_setup_state.js new file mode 100644 index 0000000000..2e4921b4bf --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_setup_state.js @@ -0,0 +1,769 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { TabsSetupFlowManager } = ChromeUtils.importESModule( + "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs" +); + +const FXA_CONTINUE_EVENT = [ + ["firefoxview", "entered", "firefoxview", undefined], + ["firefoxview", "fxa_continue", "sync", undefined], +]; + +const FXA_MOBILE_EVENT = [ + ["firefoxview", "entered", "firefoxview", undefined], + ["firefoxview", "fxa_mobile", "sync", undefined, { has_devices: "false" }], +]; + +var gMockFxaDevices = null; +var gUIStateStatus; + +function promiseSyncReady() { + let service = Cc["@mozilla.org/weave/service;1"].getService(Ci.nsISupports) + .wrappedJSObject; + return service.whenLoaded(); +} + +var gSandbox; + +async function setupWithDesktopDevices() { + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + }, + { + id: 2, + name: "Other Device", + type: "desktop", + }, + ], + }); + + await SpecialPowers.pushPrefEnv({ + set: [["services.sync.engine.tabs", true]], + }); + return sandbox; +} +add_setup(async function() { + registerCleanupFunction(() => { + // reset internal state so it doesn't affect the next tests + TabsSetupFlowManager.resetInternalState(); + }); + + // gSync.init() is called in a requestIdleCallback. Force its initialization. + gSync.init(); + + registerCleanupFunction(async function() { + Services.prefs.clearUserPref("services.sync.engine.tabs"); + await tearDown(gSandbox); + }); + // set tab sync false so we don't skip setup states + await SpecialPowers.pushPrefEnv({ + set: [["services.sync.engine.tabs", false]], + }); +}); + +add_task(async function test_unconfigured_initial_state() { + await clearAllParentTelemetryEvents(); + // test with the pref set to show FEATURE TOUR CALLOUT + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.firefox-view.feature-tour", + JSON.stringify({ + screen: `FEATURE_CALLOUT_1`, + complete: false, + }), + ], + ], + }); + const sandbox = setupMocks({ + state: UIState.STATUS_NOT_CONFIGURED, + syncEnabled: false, + }); + await withFirefoxView({}, async browser => { + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view1", + }); + checkMobilePromo(browser, { + mobilePromo: false, + mobileConfirmation: false, + }); + + await BrowserTestUtils.synthesizeMouseAtCenter( + 'button[data-action="view1-primary-action"]', + {}, + browser + ); + + await TestUtils.waitForCondition( + () => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + return events && events.length >= 2; + }, + "Waiting for entered and fxa_continue firefoxview telemetry events.", + 200, + 100 + ); + + TelemetryTestUtils.assertEvents( + FXA_CONTINUE_EVENT, + { category: "firefoxview" }, + { clear: true, process: "parent" } + ); + }); + await tearDown(sandbox); +}); + +add_task(async function test_signed_in() { + await clearAllParentTelemetryEvents(); + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + }, + ], + }); + + await withFirefoxView({}, async browser => { + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view2", + }); + is( + fxAccounts.device.recentDeviceList?.length, + 1, + "Just 1 device connected" + ); + checkMobilePromo(browser, { + mobilePromo: false, + mobileConfirmation: false, + }); + + await BrowserTestUtils.synthesizeMouseAtCenter( + 'button[data-action="view2-primary-action"]', + {}, + browser + ); + + await TestUtils.waitForCondition( + () => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + return events && events.length >= 2; + }, + "Waiting for entered and fxa_mobile firefoxview telemetry events.", + 200, + 100 + ); + + TelemetryTestUtils.assertEvents( + FXA_MOBILE_EVENT, + { category: "firefoxview" }, + { clear: true, process: "parent" } + ); + }); + await tearDown(sandbox); +}); + +add_task(async function test_support_links() { + await clearAllParentTelemetryEvents(); + setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + }, + ], + }); + await withFirefoxView({ win: window }, async browser => { + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view2", + }); + const { document } = browser.contentWindow; + const container = document.getElementById("tab-pickup-container"); + const supportLinks = Array.from( + container.querySelectorAll("a[href]") + ).filter(a => !!a.href); + is(supportLinks.length, 2, "Support links have non-empty hrefs"); + }); +}); + +add_task(async function test_2nd_desktop_connected() { + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + }, + { + id: 2, + name: "Other Device", + type: "desktop", + }, + ], + }); + await withFirefoxView({}, async browser => { + // ensure tab sync is false so we don't skip onto next step + ok( + !Services.prefs.getBoolPref("services.sync.engine.tabs", false), + "services.sync.engine.tabs is initially false" + ); + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view3", + }); + + is(fxAccounts.device.recentDeviceList?.length, 2, "2 devices connected"); + ok( + fxAccounts.device.recentDeviceList?.every( + device => device.type !== "mobile" && device.type !== "tablet" + ), + "No connected device is type:mobile or type:tablet" + ); + checkMobilePromo(browser, { + mobilePromo: false, + mobileConfirmation: false, + }); + }); + await tearDown(sandbox); +}); + +add_task(async function test_mobile_connected() { + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + }, + { + id: 2, + name: "Other Device", + type: "mobile", + }, + ], + }); + await withFirefoxView({}, async browser => { + // ensure tab sync is false so we don't skip onto next step + ok( + !Services.prefs.getBoolPref("services.sync.engine.tabs", false), + "services.sync.engine.tabs is initially false" + ); + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view3", + }); + + is(fxAccounts.device.recentDeviceList?.length, 2, "2 devices connected"); + ok( + fxAccounts.device.recentDeviceList?.some( + device => device.type == "mobile" + ), + "A connected device is type:mobile" + ); + checkMobilePromo(browser, { + mobilePromo: false, + mobileConfirmation: false, + }); + }); + await tearDown(sandbox); +}); + +add_task(async function test_tablet_connected() { + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + }, + { + id: 2, + name: "Other Device", + type: "tablet", + }, + ], + }); + await withFirefoxView({}, async browser => { + // ensure tab sync is false so we don't skip onto next step + ok( + !Services.prefs.getBoolPref("services.sync.engine.tabs", false), + "services.sync.engine.tabs is initially false" + ); + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view3", + }); + + is(fxAccounts.device.recentDeviceList?.length, 2, "2 devices connected"); + ok( + fxAccounts.device.recentDeviceList?.some( + device => device.type == "tablet" + ), + "A connected device is type:tablet" + ); + checkMobilePromo(browser, { + mobilePromo: false, + mobileConfirmation: false, + }); + }); + await tearDown(sandbox); +}); + +add_task(async function test_tab_sync_enabled() { + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + }, + { + id: 2, + name: "Other Device", + type: "mobile", + }, + ], + }); + await withFirefoxView({}, async browser => { + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + // test initial state, with the pref not enabled + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view3", + }); + checkMobilePromo(browser, { + mobilePromo: false, + mobileConfirmation: false, + }); + + // test with the pref toggled on + await SpecialPowers.pushPrefEnv({ + set: [["services.sync.engine.tabs", true]], + }); + await waitForElementVisible(browser, "#tabpickup-steps", false); + checkMobilePromo(browser, { + mobilePromo: false, + mobileConfirmation: false, + }); + + // reset and test clicking the action button + await SpecialPowers.popPrefEnv(); + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view3", + }); + checkMobilePromo(browser, { + mobilePromo: false, + mobileConfirmation: false, + }); + + const actionButton = browser.contentWindow.document.querySelector( + "#tabpickup-steps-view3 button.primary" + ); + actionButton.click(); + + await waitForElementVisible(browser, "#tabpickup-steps", false); + checkMobilePromo(browser, { + mobilePromo: false, + mobileConfirmation: false, + }); + await waitForElementVisible(browser, ".featureCallout .FEATURE_CALLOUT_1"); + ok(true, "Tab pickup product tour screen renders when sync is enabled"); + ok( + Services.prefs.getBoolPref("services.sync.engine.tabs", false), + "tab sync pref should be enabled after button click" + ); + }); + await tearDown(sandbox); +}); + +add_task(async function test_mobile_promo() { + const sandbox = await setupWithDesktopDevices(); + await withFirefoxView({}, async browser => { + // ensure last tab fetch was just now so we don't get the loading state + await touchLastTabFetch(); + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + await waitForElementVisible(browser, ".synced-tabs-container"); + is(fxAccounts.device.recentDeviceList?.length, 2, "2 devices connected"); + + info("checking mobile promo, should be visible now"); + checkMobilePromo(browser, { + mobilePromo: true, + mobileConfirmation: false, + }); + + gMockFxaDevices.push({ + id: 3, + name: "Mobile Device", + type: "mobile", + }); + + Services.obs.notifyObservers(null, "fxaccounts:devicelist_updated"); + + // Wait for the async refreshDeviceList(), + // which should result in the promo being hidden + await waitForElementVisible( + browser, + "#tab-pickup-container > .promo-box", + false + ); + is(fxAccounts.device.recentDeviceList?.length, 3, "3 devices connected"); + checkMobilePromo(browser, { + mobilePromo: false, + mobileConfirmation: true, + }); + + info("checking mobile promo disappears on log out"); + gMockFxaDevices.pop(); + Services.obs.notifyObservers(null, "fxaccounts:devicelist_updated"); + await waitForElementVisible( + browser, + "#tab-pickup-container > .promo-box", + true + ); + checkMobilePromo(browser, { + mobilePromo: true, + mobileConfirmation: false, + }); + + // Set the UIState to what we expect when the user signs out + gUIStateStatus = UIState.STATUS_NOT_CONFIGURED; + gUIStateSyncEnabled = undefined; + + info( + "notifying that we've signed out of fxa, UIState.get().status:" + + UIState.get().status + ); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + info("waiting for setup card 1 to appear again"); + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view1", + }); + checkMobilePromo(browser, { + mobilePromo: false, + mobileConfirmation: false, + }); + }); + await tearDown(sandbox); +}); + +add_task(async function test_mobile_promo_pref() { + const sandbox = await setupWithDesktopDevices(); + await SpecialPowers.pushPrefEnv({ + set: [[MOBILE_PROMO_DISMISSED_PREF, true]], + }); + await withFirefoxView({}, async browser => { + // ensure tab sync is false so we don't skip onto next step + info("starting test, will notify of UIState update"); + // ensure last tab fetch was just now so we don't get the loading state + await touchLastTabFetch(); + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + await waitForElementVisible(browser, ".synced-tabs-container"); + is(fxAccounts.device.recentDeviceList?.length, 2, "2 devices connected"); + + info("checking mobile promo, should be still hidden because of the pref"); + checkMobilePromo(browser, { + mobilePromo: false, + mobileConfirmation: false, + }); + + // reset the dismissed pref, which should case the promo to get shown + await SpecialPowers.popPrefEnv(); + await waitForElementVisible( + browser, + "#tab-pickup-container > .promo-box", + true + ); + + const promoElem = browser.contentWindow.document.querySelector( + "#tab-pickup-container > .promo-box" + ); + const promoElemClose = promoElem.querySelector(".close"); + ok(promoElemClose.hasAttribute("aria-label"), "Button has an a11y name"); + // check that dismissing the promo sets the pref + info("Clicking the promo close button: " + promoElemClose); + EventUtils.sendMouseEvent({ type: "click" }, promoElemClose); + + info("Check the promo box got hidden"); + BrowserTestUtils.is_hidden(promoElem); + ok( + SpecialPowers.getBoolPref(MOBILE_PROMO_DISMISSED_PREF), + "Promo pref is updated when close is clicked" + ); + }); + await tearDown(sandbox); +}); + +add_task(async function test_mobile_promo_windows() { + // make sure interacting with the promo and success confirmation in one window + // also updates the others + const sandbox = await setupWithDesktopDevices(); + await withFirefoxView({}, async browser => { + // ensure last tab fetch was just now so we don't get the loading state + await touchLastTabFetch(); + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + await waitForElementVisible(browser, ".synced-tabs-container"); + is(fxAccounts.device.recentDeviceList?.length, 2, "2 devices connected"); + + info("checking mobile promo is visible"); + checkMobilePromo(browser, { + mobilePromo: true, + mobileConfirmation: false, + }); + + info( + "opening new window, pref is: " + + SpecialPowers.getBoolPref("browser.tabs.firefox-view") + ); + + let win2 = await BrowserTestUtils.openNewBrowserWindow(); + info("Got window, now opening Firefox View in it"); + await withFirefoxView( + { resetFlowManager: false, win: win2 }, + async win2Browser => { + info("In withFirefoxView taskFn for win2"); + // promo should be visible in the 2nd window too + info("check mobile promo is visible in the new window"); + checkMobilePromo(win2Browser, { + mobilePromo: true, + mobileConfirmation: false, + }); + + // add the mobile device to get the success confirmation in both instances + info("add a mobile device and send device_connected notification"); + gMockFxaDevices.push({ + id: 3, + name: "Mobile Device", + type: "mobile", + }); + + Services.obs.notifyObservers(null, "fxaccounts:devicelist_updated"); + is( + fxAccounts.device.recentDeviceList?.length, + 3, + "3 devices connected" + ); + + // Wait for the async refreshDevices(), + // which should result in the promo being hidden + info("waiting for the confirmation box to be visible"); + await waitForElementVisible( + win2Browser, + "#tab-pickup-container > .promo-box", + false + ); + + for (let fxviewBrowser of [browser, win2Browser]) { + info( + "checking promo is hidden and confirmation is visible in each window" + ); + checkMobilePromo(fxviewBrowser, { + mobilePromo: false, + mobileConfirmation: true, + }); + } + + // dismiss the confirmation and check its gone from both instances + const confirmBox = win2Browser.contentWindow.document.querySelector( + "#tab-pickup-container > .confirmation-message-box" + ); + const closeButton = confirmBox.querySelector(".close"); + ok(closeButton.hasAttribute("aria-label"), "Button has an a11y name"); + EventUtils.sendMouseEvent({ type: "click" }, closeButton, win2); + BrowserTestUtils.is_hidden(confirmBox); + + for (let fxviewBrowser of [browser, win2Browser]) { + checkMobilePromo(fxviewBrowser, { + mobilePromo: false, + mobileConfirmation: false, + }); + } + } + ); + await BrowserTestUtils.closeWindow(win2); + }); + await tearDown(sandbox); +}); + +async function mockFxaDeviceConnected(win) { + // We use an existing tab to navigate to the final "device connected" url + // in order to fake the fxa device sync process + const url = "https://example.org/pair/auth/complete"; + is(win.gBrowser.tabs.length, 3, "Tabs strip should contain three tabs"); + + BrowserTestUtils.loadURI(win.gBrowser.selectedTab.linkedBrowser, url); + + await BrowserTestUtils.browserLoaded( + win.gBrowser.selectedTab.linkedBrowser, + null, + url + ); + + is( + win.gBrowser.selectedTab.linkedBrowser.currentURI.filePath, + "/pair/auth/complete", + "/pair/auth/complete is the selected tab" + ); +} + +add_task(async function test_close_device_connected_tab() { + // test that when a device has been connected to sync we close + // that tab after the user is directed back to firefox view + + // Ensure we are in the correct state to start the task. + TabsSetupFlowManager.resetInternalState(); + await SpecialPowers.pushPrefEnv({ + set: [["identity.fxaccounts.remote.root", "https://example.org/"]], + }); + let win = await BrowserTestUtils.openNewBrowserWindow(); + let fxViewTab = await openFirefoxViewTab(win); + + await waitForVisibleSetupStep(win.gBrowser, { + expectedVisible: "#tabpickup-steps-view1", + }); + + let actionButton = win.gBrowser.contentWindow.document.querySelector( + "#tabpickup-steps-view1 button.primary" + ); + // initiate the sign in flow from Firefox View, to check that didFxaTabOpen is set + let tabSwitched = BrowserTestUtils.waitForEvent( + win.gBrowser, + "TabSwitchDone" + ); + actionButton.click(); + await tabSwitched; + + // fake the end point of the device syncing flow + await mockFxaDeviceConnected(win); + let deviceConnectedTab = win.gBrowser.tabs[2]; + + // remove the blank tab opened with the browser to check that we don't + // close the window when the "Device connected" tab is closed + const newTab = win.gBrowser.tabs.find( + tab => tab != deviceConnectedTab && tab != fxViewTab + ); + let removedTab = BrowserTestUtils.waitForTabClosing(newTab); + BrowserTestUtils.removeTab(newTab); + await removedTab; + + is(win.gBrowser.tabs.length, 2, "Tabs strip should only contain two tabs"); + + is( + win.gBrowser.selectedTab.linkedBrowser.currentURI.filePath, + "/pair/auth/complete", + "/pair/auth/complete is the selected tab" + ); + + // we use this instead of BrowserTestUtils.switchTab to get back to the firefox view tab + // because this more accurately reflects how this tab is selected - via a custom onmousedown + // and command that calls FirefoxViewHandler.openTab (both when the user manually clicks the tab + // and when navigating from the fxa Device Connected tab, which also calls FirefoxViewHandler.openTab) + await EventUtils.synthesizeMouseAtCenter( + win.document.getElementById("firefox-view-button"), + { type: "mousedown" }, + win + ); + + is(win.gBrowser.tabs.length, 2, "Tabs strip should only contain two tabs"); + + is( + win.gBrowser.tabs[0].linkedBrowser.currentURI.filePath, + "firefoxview", + "First tab is Firefox view" + ); + + is( + win.gBrowser.tabs[1].linkedBrowser.currentURI.filePath, + "newtab", + "Second tab is about:newtab" + ); + + // now simulate the signed-in state with the prompt to download + // and sync mobile + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + }, + ], + }); + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + await waitForVisibleSetupStep(win.gBrowser, { + expectedVisible: "#tabpickup-steps-view2", + }); + + actionButton = win.gBrowser.contentWindow.document.querySelector( + "#tabpickup-steps-view2 button.primary" + ); + // initiate the connect device (mobile) flow from Firefox View, to check that didFxaTabOpen is set + tabSwitched = BrowserTestUtils.waitForEvent(win.gBrowser, "TabSwitchDone"); + actionButton.click(); + await tabSwitched; + // fake the end point of the device syncing flow + await mockFxaDeviceConnected(win); + + await EventUtils.synthesizeMouseAtCenter( + win.document.getElementById("firefox-view-button"), + { type: "mousedown" }, + win + ); + is(win.gBrowser.tabs.length, 2, "Tabs strip should only contain two tabs"); + + is( + win.gBrowser.tabs[0].linkedBrowser.currentURI.filePath, + "firefoxview", + "First tab is Firefox view" + ); + + is( + win.gBrowser.tabs[1].linkedBrowser.currentURI.filePath, + "newtab", + "Second tab is about:newtab" + ); + + // cleanup time + await tearDown(sandbox); + await SpecialPowers.popPrefEnv(); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_setup_synced_tabs_loading.js b/browser/components/firefoxview/tests/browser/browser_setup_synced_tabs_loading.js new file mode 100644 index 0000000000..63518e79c0 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_setup_synced_tabs_loading.js @@ -0,0 +1,180 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { TabsSetupFlowManager } = ChromeUtils.importESModule( + "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs" +); + +XPCOMUtils.defineLazyModuleGetters(globalThis, { + SyncedTabs: "resource://services-sync/SyncedTabs.jsm", +}); + +async function tearDown(sandbox) { + sandbox?.restore(); + Services.prefs.clearUserPref("services.sync.lastTabFetch"); +} + +function checkLoadingState(browser, isLoading = false) { + const { document } = browser.contentWindow; + const tabsContainer = document.querySelector("#tabpickup-tabs-container"); + const tabsList = document.querySelector( + "#tabpickup-tabs-container tab-pickup-list" + ); + const loadingElem = document.querySelector( + "#tabpickup-tabs-container .loading-content" + ); + const setupElem = document.querySelector("#tabpickup-steps"); + + if (isLoading) { + ok( + tabsContainer.classList.contains("loading"), + "Tabs container has loading class" + ); + BrowserTestUtils.is_visible( + loadingElem, + "Loading content is visible when loading" + ); + !tabsList || + BrowserTestUtils.is_hidden( + tabsList, + "Synced tabs list is not visible when loading" + ); + !setupElem || + BrowserTestUtils.is_hidden( + setupElem, + "Setup content is not visible when loading" + ); + } else { + ok( + !tabsContainer.classList.contains("loading"), + "Tabs container has no loading class" + ); + !loadingElem || + BrowserTestUtils.is_hidden( + loadingElem, + "Loading content is not visible when tabs are loaded" + ); + BrowserTestUtils.is_visible( + tabsList, + "Synced tabs list is visible when loaded" + ); + !setupElem || + BrowserTestUtils.is_hidden( + setupElem, + "Setup content is not visible when tabs are loaded" + ); + } +} + +function setupMocks(recentTabs, syncEnabled = true) { + const sandbox = (gSandbox = setupRecentDeviceListMocks()); + sandbox.stub(SyncedTabs, "getRecentTabs").callsFake(() => { + info( + `SyncedTabs.getRecentTabs will return a promise resolving to ${recentTabs.length} tabs` + ); + return Promise.resolve(recentTabs); + }); + return sandbox; +} + +add_setup(async function() { + registerCleanupFunction(() => { + // reset internal state so it doesn't affect the next tests + TabsSetupFlowManager.resetInternalState(); + }); + + // gSync.init() is called in a requestIdleCallback. Force its initialization. + gSync.init(); + + registerCleanupFunction(async function() { + Services.prefs.clearUserPref("services.sync.engine.tabs"); + await tearDown(gSandbox); + }); +}); + +add_task(async function test_tab_sync_loading() { + // empty synced tabs, so we're relying on tabs.changed or sync:finish notifications to clear the waiting state + const recentTabsData = []; + const sandbox = setupMocks(recentTabsData); + // stub syncTabs so it resolves to true - meaning yes it will trigger a sync, which is the case + // we want to cover in this test. + sandbox.stub(SyncedTabs._internal, "syncTabs").resolves(true); + + await withFirefoxView({}, async browser => { + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + const { document } = browser.contentWindow; + const tabsContainer = document.querySelector("#tabpickup-tabs-container"); + + await waitForElementVisible(browser, "#tabpickup-steps", false); + await waitForElementVisible(browser, "#tabpickup-tabs-container", true); + checkMobilePromo(browser, { + mobilePromo: false, + mobileConfirmation: false, + }); + + ok(TabsSetupFlowManager.waitingForTabs, "waitingForTabs is true"); + checkLoadingState(browser, true); + + Services.obs.notifyObservers(null, "services.sync.tabs.changed"); + + await BrowserTestUtils.waitForMutationCondition( + tabsContainer, + { attributeFilter: ["class"], attributes: true }, + () => { + return !tabsContainer.classList.contains("loading"); + } + ); + checkLoadingState(browser, false); + checkMobilePromo(browser, { + mobilePromo: false, + mobileConfirmation: false, + }); + }); + await tearDown(sandbox); +}); + +add_task(async function test_tab_no_sync() { + // Ensure we take down the waiting message if SyncedTabs determines it doesnt need to sync + const recentTabsData = []; + const sandbox = setupMocks(recentTabsData); + // stub syncTabs so it resolves to false - meaning it will not trigger a sync, which is the case + // we want to cover in this test. + sandbox.stub(SyncedTabs._internal, "syncTabs").resolves(false); + + await withFirefoxView({}, async browser => { + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + await waitForElementVisible(browser, "#tabpickup-steps", false); + await waitForElementVisible(browser, "#tabpickup-tabs-container", true); + + ok(!TabsSetupFlowManager.waitingForTabs, "waitingForTabs is false"); + checkLoadingState(browser, false); + }); + await tearDown(sandbox); +}); + +add_task(async function test_recent_tabs_loading() { + // Simulate stale data by setting lastTabFetch to 10mins ago + const TEN_MINUTES_MS = 1000 * 60 * 10; + const staleFetchSeconds = Math.floor((Date.now() - TEN_MINUTES_MS) / 1000); + info("updating lastFetch:" + staleFetchSeconds); + Services.prefs.setIntPref("services.sync.lastTabFetch", staleFetchSeconds); + + // cached tabs data is available, so we shouldn't wait on lastTabFetch pref value + const recentTabsData = structuredClone(syncedTabsData1[0].tabs); + const sandbox = setupMocks(recentTabsData); + + await withFirefoxView({}, async browser => { + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + await waitForElementVisible(browser, "#tabpickup-steps", false); + await waitForElementVisible(browser, "#tabpickup-tabs-container", true); + checkMobilePromo(browser, { + mobilePromo: false, + mobileConfirmation: false, + }); + checkLoadingState(browser, false); + }); + await tearDown(sandbox); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_sync_admin_disabled.js b/browser/components/firefoxview/tests/browser/browser_sync_admin_disabled.js new file mode 100644 index 0000000000..43c0663d76 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_sync_admin_disabled.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { TabsSetupFlowManager } = ChromeUtils.importESModule( + "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs" +); + +var gSandbox; + +add_setup(async function() { + Services.prefs.lockPref("identity.fxaccounts.enabled"); + + registerCleanupFunction(() => { + gSandbox?.restore(); + Services.prefs.clearUserPref("services.sync.lastTabFetch"); + Services.prefs.unlockPref("identity.fxaccounts.enabled"); + Services.prefs.clearUserPref("identity.fxaccounts.enabled"); + // reset internal state so it doesn't affect the next tests + TabsSetupFlowManager.resetInternalState(); + }); + + // gSync.init() is called in a requestIdleCallback. Force its initialization. + gSync.init(); +}); + +add_task(async function test_sync_admin_disabled() { + const sandbox = (gSandbox = sinon.createSandbox()); + sandbox.stub(UIState, "get").callsFake(() => { + return { + status: UIState.STATUS_NOT_CONFIGURED, + syncEnabled: false, + }; + }); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + is( + Services.prefs.getBoolPref("identity.fxaccounts.enabled"), + true, + "Expected identity.fxaccounts.enabled pref to be false" + ); + + is( + Services.prefs.prefIsLocked("identity.fxaccounts.enabled"), + true, + "Expected identity.fxaccounts.enabled pref to be locked" + ); + + await waitForVisibleSetupStep(browser, { + expectedVisible: "#tabpickup-steps-view0", + }); + + const errorStateHeader = document.querySelector( + "#tabpickup-steps-view0-header" + ); + + await BrowserTestUtils.waitForMutationCondition( + errorStateHeader, + { childList: true }, + () => errorStateHeader.textContent.includes("disabled") + ); + + ok( + errorStateHeader + .getAttribute("data-l10n-id") + .includes("fxa-admin-disabled"), + "Correct message should show when fxa is disabled by an admin" + ); + }); + Services.prefs.unlockPref("identity.fxaccounts.enabled"); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_tab_close_last_tab.js b/browser/components/firefoxview/tests/browser/browser_tab_close_last_tab.js new file mode 100644 index 0000000000..4af862d40c --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_tab_close_last_tab.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const URL = "https://example.com/"; + +add_task(async function closing_last_tab_should_not_switch_to_fx_view() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.tabs.closeWindowWithLastTab", false]], + }); + info("Opening window..."); + const win = await BrowserTestUtils.openNewBrowserWindow({ + waitForTabURL: "about:newtab", + }); + const firstTab = win.gBrowser.selectedTab; + info("Opening Firefox View tab..."); + await openFirefoxViewTab(win); + info("Switch back to new tab..."); + await BrowserTestUtils.switchTab(win.gBrowser, firstTab); + info("Load web page in new tab..."); + const loaded = BrowserTestUtils.browserLoaded( + win.gBrowser.selectedBrowser, + false, + URL + ); + BrowserTestUtils.loadURI(win.gBrowser.selectedBrowser, URL); + await loaded; + info("Opening new browser tab..."); + const secondTab = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + URL + ); + info("Close all broswer tabs..."); + await BrowserTestUtils.removeTab(firstTab); + await BrowserTestUtils.removeTab(secondTab); + isnot( + win.gBrowser.selectedTab, + win.FirefoxViewHandler.tab, + "The selected tab should not be the Firefox View tab" + ); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_tab_on_close_warning.js b/browser/components/firefoxview/tests/browser/browser_tab_on_close_warning.js new file mode 100644 index 0000000000..cd6d30f3d1 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_tab_on_close_warning.js @@ -0,0 +1,63 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +class DialogObserver { + constructor() { + this.wasOpened = false; + Services.obs.addObserver(this, "common-dialog-loaded"); + } + cleanup() { + Services.obs.removeObserver(this, "common-dialog-loaded"); + } + observe(win, topic) { + if (topic == "common-dialog-loaded") { + this.wasOpened = true; + // Close dialog. + win.document + .querySelector("dialog") + .getButton("cancel") + .click(); + } + } +} + +add_task( + async function on_close_warning_should_not_show_for_firefox_view_tab() { + const dialogObserver = new DialogObserver(); + await SpecialPowers.pushPrefEnv({ + set: [["browser.tabs.warnOnClose", true]], + }); + info("Opening window..."); + const win = await BrowserTestUtils.openNewBrowserWindow(); + info("Opening Firefox View tab..."); + await openFirefoxViewTab(win); + info("Trigger warnAboutClosingWindow()"); + win.BrowserTryToCloseWindow(); + await BrowserTestUtils.closeWindow(win); + ok(!dialogObserver.wasOpened, "Dialog was not opened"); + dialogObserver.cleanup(); + } +); + +add_task( + async function on_close_warning_should_not_show_for_firefox_view_tab_non_macos() { + let initialTab = gBrowser.selectedTab; + const dialogObserver = new DialogObserver(); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.tabs.warnOnClose", true], + ["browser.warnOnQuit", true], + ], + }); + info("Opening Firefox View tab..."); + await openFirefoxViewTab(window); + info('Trigger "quit-application-requested"'); + canQuitApplication("lastwindow", "close-button"); + ok(!dialogObserver.wasOpened, "Dialog was not opened"); + await BrowserTestUtils.switchTab(gBrowser, initialTab); + closeFirefoxViewTab(window); + dialogObserver.cleanup(); + } +); diff --git a/browser/components/firefoxview/tests/browser/browser_tab_pickup_list.js b/browser/components/firefoxview/tests/browser/browser_tab_pickup_list.js new file mode 100644 index 0000000000..b7fa3a2e5a --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_tab_pickup_list.js @@ -0,0 +1,607 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { TabsSetupFlowManager } = ChromeUtils.importESModule( + "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs" +); + +XPCOMUtils.defineLazyModuleGetters(globalThis, { + SyncedTabs: "resource://services-sync/SyncedTabs.jsm", +}); + +const twoTabs = [ + { + type: "tab", + title: "Phabricator Home", + url: "https://phabricator.services.mozilla.com/", + icon: "https://phabricator.services.mozilla.com/favicon.d25d81d39065.ico", + lastUsed: 1655745700, // Mon, 20 Jun 2022 17:21:40 GMT + }, + { + type: "tab", + title: "Firefox Privacy Notice", + url: "https://www.mozilla.org/en-US/privacy/firefox/", + icon: + "https://www.mozilla.org/media/img/favicons/mozilla/favicon.d25d81d39065.ico", + lastUsed: 1655745700, // Mon, 20 Jun 2022 17:21:40 GMT + }, +]; +const syncedTabsData2 = structuredClone(syncedTabsData1); +syncedTabsData2[1].tabs = [...syncedTabsData2[1].tabs, ...twoTabs]; + +const syncedTabsData3 = [ + { + id: 1, + type: "client", + name: "My desktop", + clientType: "desktop", + lastModified: 1655730486760, + tabs: [ + { + type: "tab", + title: "Sandboxes - Sinon.JS", + url: "https://sinonjs.org/releases/latest/sandbox/", + icon: "https://sinonjs.org/assets/images/favicon.png", + lastUsed: 1655391592, // Thu Jun 16 2022 14:59:52 GMT+0000 + }, + ], + }, +]; + +const syncedTabsData4 = structuredClone(syncedTabsData3); +syncedTabsData4[0].tabs = [...syncedTabsData4[0].tabs, ...twoTabs]; + +const syncedTabsData5 = [ + { + id: 1, + type: "client", + name: "My desktop", + clientType: "desktop", + lastModified: Date.now(), + tabs: [ + { + type: "tab", + title: "Example2", + url: "https://example.com", + icon: "https://example/favicon.png", + lastUsed: Math.floor((Date.now() - 1000 * 60) / 1000), // This is one minute from now, which is below the threshold for 'Just now' + }, + ], + }, +]; + +const NO_TABS_EVENTS = [ + ["firefoxview", "entered", "firefoxview", undefined], + ["firefoxview", "synced_tabs", "tabs", undefined, { count: "0" }], +]; + +const TAB_PICKUP_EVENT = [ + ["firefoxview", "entered", "firefoxview", undefined], + ["firefoxview", "synced_tabs", "tabs", undefined, { count: "1" }], + [ + "firefoxview", + "tab_pickup", + "tabs", + undefined, + { position: "1", deviceType: "desktop" }, + ], +]; + +const TAB_PICKUP_OPEN_EVENT = [ + ["firefoxview", "tab_pickup_open", "tabs", "false"], +]; + +registerCleanupFunction(async function() { + cleanup_tab_pickup(); +}); + +add_task(async function test_tab_list_ordering() { + const sandbox = setupRecentDeviceListMocks(); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + let mockTabs1 = getMockTabData(syncedTabsData1); + let mockTabs2 = getMockTabData(syncedTabsData2); + syncedTabsMock.callsFake(() => { + info( + `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${mockTabs1.length} tabs\n` + ); + return Promise.resolve(mockTabs1); + }); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + await setupListState(browser); + + testVisibility(browser, { + expectedVisible: { + "ol.synced-tabs-list": true, + }, + }); + + ok( + document.querySelector("ol.synced-tabs-list").children.length === 3, + "synced-tabs-list should have three list items" + ); + + ok( + document + .querySelector("ol.synced-tabs-list") + .firstChild.textContent.includes("Internet for people, not profits"), + "First list item in synced-tabs-list is in the correct order" + ); + + ok( + document + .querySelector("ol.synced-tabs-list") + .children[2].textContent.includes("Sandboxes - Sinon.JS"), + "Last list item in synced-tabs-list is in the correct order" + ); + + syncedTabsMock.returns(mockTabs2); + // Initiate a synced tabs update + Services.obs.notifyObservers(null, "services.sync.tabs.changed"); + + const syncedTabsList = document.querySelector("ol.synced-tabs-list"); + // first list item has been updated + await BrowserTestUtils.waitForMutationCondition( + syncedTabsList, + { childList: true }, + () => syncedTabsList.firstChild.textContent.includes("Firefox") + ); + + ok( + document.querySelector("ol.synced-tabs-list").children.length === 3, + "Synced-tabs-list should still have three list items" + ); + + ok( + document + .querySelector("ol.synced-tabs-list") + .children[1].textContent.includes("Phabricator"), + "Second list item in synced-tabs-list has been updated" + ); + + ok( + document + .querySelector("ol.synced-tabs-list") + .children[2].textContent.includes("Internet for people, not profits"), + "Last list item in synced-tabs-list has been updated" + ); + + sandbox.restore(); + cleanup_tab_pickup(); + }); +}); + +add_task(async function test_empty_list_items() { + const sandbox = setupRecentDeviceListMocks(); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + let mockTabs1 = getMockTabData(syncedTabsData3); + let mockTabs2 = getMockTabData(syncedTabsData4); + syncedTabsMock.callsFake(() => { + info( + `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${mockTabs1.length} tabs\n` + ); + return Promise.resolve(mockTabs1); + }); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + await setupListState(browser); + + testVisibility(browser, { + expectedVisible: { + "ol.synced-tabs-list": true, + }, + }); + + ok( + document.querySelector("ol.synced-tabs-list").children.length === 3, + "synced-tabs-list should have three list items" + ); + + ok( + document + .querySelector("ol.synced-tabs-list") + .firstChild.textContent.includes("Sandboxes - Sinon.JS"), + "First list item in synced-tabs-list is in the correct order" + ); + + ok( + document + .querySelector("ol.synced-tabs-list") + .children[1].classList.contains("synced-tab-li-placeholder"), + "Second list item in synced-tabs-list should be a placeholder" + ); + + ok( + document + .querySelector("ol.synced-tabs-list") + .lastChild.classList.contains("synced-tab-li-placeholder"), + "Last list item in synced-tabs-list should be a placeholder" + ); + + syncedTabsMock.returns(mockTabs2); + // Initiate a synced tabs update + Services.obs.notifyObservers(null, "services.sync.tabs.changed"); + + const syncedTabsList = document.querySelector("ol.synced-tabs-list"); + // first list item has been updated + await BrowserTestUtils.waitForMutationCondition( + syncedTabsList, + { childList: true }, + () => + syncedTabsList.firstChild.textContent.includes("Firefox Privacy Notice") + ); + + ok( + document + .querySelector("ol.synced-tabs-list") + .children[1].textContent.includes("Phabricator"), + "Second list item in synced-tabs-list has been updated" + ); + + ok( + document + .querySelector("ol.synced-tabs-list") + .lastChild.textContent.includes("Sandboxes - Sinon.JS"), + "Last list item in synced-tabs-list has been updated" + ); + + sandbox.restore(); + cleanup_tab_pickup(); + }); +}); + +add_task(async function test_empty_list() { + await clearAllParentTelemetryEvents(); + const sandbox = setupRecentDeviceListMocks(); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + let mockTabs1 = getMockTabData([]); + let mockTabs2 = getMockTabData(syncedTabsData4); + syncedTabsMock.callsFake(() => { + info( + `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${mockTabs1.length} tabs\n` + ); + return Promise.resolve(mockTabs1); + }); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + await setupListState(browser); + info("setupListState complete, checking placeholder and list visibility"); + testVisibility(browser, { + expectedVisible: { + "#synced-tabs-placeholder": true, + "ol.synced-tabs-list": false, + }, + }); + + ok( + document + .querySelector("#synced-tabs-placeholder") + .classList.contains("empty-container"), + "collapsible container should have correct styling when the list is empty" + ); + + await TestUtils.waitForCondition( + () => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + return events && events.length >= 2; + }, + "Waiting for entered and synced_tabs firefoxview telemetry events.", + 200, + 100 + ); + + TelemetryTestUtils.assertEvents( + NO_TABS_EVENTS, + { category: "firefoxview" }, + { clear: true, process: "parent" } + ); + + syncedTabsMock.callsFake(() => { + info( + `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${mockTabs2.length} tabs\n` + ); + return Promise.resolve(mockTabs2); + }); + // Initiate a synced tabs update + Services.obs.notifyObservers(null, "services.sync.tabs.changed"); + + const syncedTabsList = document.querySelector("ol.synced-tabs-list"); + await BrowserTestUtils.waitForMutationCondition( + syncedTabsList, + { childList: true }, + () => syncedTabsList.children.length + ); + + testVisibility(browser, { + expectedVisible: { + "#synced-tabs-placeholder": false, + "ol.synced-tabs-list": true, + }, + }); + + sandbox.restore(); + cleanup_tab_pickup(); + }); +}); + +add_task(async function test_time_updates_correctly() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.tabs.firefox-view.updateTimeMs", 100]], + }); + await clearAllParentTelemetryEvents(); + + const sandbox = setupRecentDeviceListMocks(); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + let mockTabs1 = getMockTabData(syncedTabsData5); + syncedTabsMock.callsFake(() => { + info( + `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${mockTabs1.length} tabs\n` + ); + return Promise.resolve(mockTabs1); + }); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + await setupListState(browser); + + let initialTimeText = document.querySelector("span.synced-tab-li-time") + .textContent; + Assert.stringContains( + initialTimeText, + "Just now", + "synced-tab-li-time text is 'Just now'" + ); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.tabs.firefox-view.updateTimeMs", 100]], + }); + + const timeLabel = document.querySelector("span.synced-tab-li-time"); + await BrowserTestUtils.waitForMutationCondition( + timeLabel, + { childList: true }, + () => !timeLabel.textContent.includes("now") + ); + + isnot( + timeLabel.textContent, + initialTimeText, + "synced-tab-li-time text has updated" + ); + + document.querySelector(".synced-tab-a").click(); + + await TestUtils.waitForCondition( + () => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + return events && events.length >= 3; + }, + "Waiting for entered, synced_tabs, and tab_pickup firefoxview telemetry events.", + 200, + 100 + ); + + TelemetryTestUtils.assertEvents( + TAB_PICKUP_EVENT, + { category: "firefoxview" }, + { clear: true, process: "parent" } + ); + + let gBrowser = browser.getTabBrowser(); + is( + gBrowser.visibleTabs.indexOf(gBrowser.selectedTab), + 0, + "Tab opened at the beginning of the tab strip" + ); + gBrowser.removeTab(gBrowser.selectedTab); + // make sure we're back on fx-view + browser.ownerGlobal.FirefoxViewHandler.openTab(); + + info("Waiting for the tab pickup summary to be visible"); + await waitForElementVisible(browser, "#tab-pickup-container > summary"); + // click on the details summary and verify telemetry gets logged for this event + await clearAllParentTelemetryEvents(); + info("clicking the summary to collapse it"); + document.querySelector("#tab-pickup-container > summary").click(); + + await TestUtils.waitForCondition( + () => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + return events && events.length >= 1; + }, + "Waiting for tab_pickup_open firefoxview telemetry event.", + 200, + 100 + ); + TelemetryTestUtils.assertEvents( + TAB_PICKUP_OPEN_EVENT, + { category: "firefoxview" }, + { clear: true, process: "parent" } + ); + + sandbox.restore(); + cleanup_tab_pickup(); + await SpecialPowers.popPrefEnv(); + }); +}); + +/** + * Ensure that tabs sync when a user reloads Firefox View. + * This is accomplished by asserting that a new set of tabs are loaded + * on page reload. + */ +add_task(async function test_tabs_sync_on_user_page_reload() { + const sandbox = setupRecentDeviceListMocks(); + sandbox.stub(SyncedTabs._internal, "syncTabs").resolves(true); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + let mockTabs1 = getMockTabData(syncedTabsData1); + let expectedTabsAfterReload = getMockTabData(syncedTabsData3); + syncedTabsMock.callsFake(() => { + info( + `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${mockTabs1.length} tabs\n` + ); + return Promise.resolve(mockTabs1); + }); + + await withFirefoxView({}, async browser => { + let reloadButton = browser.ownerDocument.getElementById("reload-button"); + + await setupListState(browser); + + let tabLoaded = BrowserTestUtils.browserLoaded(browser); + EventUtils.synthesizeMouseAtCenter(reloadButton, {}, browser.ownerGlobal); + await tabLoaded; + // Wait until the window is reloaded, then get the current instance + // of the contentWindow + const { document } = browser.contentWindow; + ok(true, "Firefox View has been reloaded"); + ok(TabsSetupFlowManager.waitingForTabs, "waitingForTabs is true"); + + syncedTabsMock.returns(expectedTabsAfterReload); + Services.obs.notifyObservers(null, "services.sync.tabs.changed"); + ok(!TabsSetupFlowManager.waitingForTabs, "waitingForTabs is false"); + + const syncedTabsList = document.querySelector("ol.synced-tabs-list"); + // The tab pickup list has been updated + await BrowserTestUtils.waitForMutationCondition( + syncedTabsList, + { childList: true }, + () => + syncedTabsList.firstChild.textContent.includes("Sandboxes - Sinon.JS") + ); + + sandbox.restore(); + cleanup_tab_pickup(); + }); +}); + +add_task(async function test_keyboard_navigation() { + // Setting this pref allows the test to run as expected on MacOS + await SpecialPowers.pushPrefEnv({ set: [["accessibility.tabfocus", 7]] }); + TabsSetupFlowManager.resetInternalState(); + + const sandbox = setupRecentDeviceListMocks(); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + let mockTabs1 = getMockTabData(syncedTabsData1); + syncedTabsMock.callsFake(() => { + info( + `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${mockTabs1.length} tabs\n` + ); + return Promise.resolve(mockTabs1); + }); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + let win = browser.ownerGlobal; + + await setupListState(browser); + const tab = (shiftKey = false) => { + info(`${shiftKey ? "Shift + Tab" : "Tab"}`); + EventUtils.synthesizeKey("KEY_Tab", { shiftKey }, win); + }; + const arrowDown = () => { + info("Arrow Down"); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + }; + const arrowUp = () => { + info("Arrow Up"); + EventUtils.synthesizeKey("KEY_ArrowUp", {}, win); + }; + const arrowLeft = () => { + info("Arrow Left"); + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win); + }; + const arrowRight = () => { + info("Arrow Right"); + EventUtils.synthesizeKey("KEY_ArrowRight", {}, win); + }; + + let syncedTabsLinks = document + .querySelector("ol.synced-tabs-list") + .querySelectorAll("a"); + let summary = document + .getElementById("tab-pickup-container") + .querySelector("summary"); + summary.focus(); + tab(); + is( + syncedTabsLinks[0], + document.activeElement, + "First synced tab should be focused" + ); + arrowDown(); + is( + syncedTabsLinks[1], + document.activeElement, + "Second synced tab should be focused" + ); + arrowDown(); + is( + syncedTabsLinks[2], + document.activeElement, + "Third synced tab should be focused" + ); + arrowDown(); + is( + syncedTabsLinks[2], + document.activeElement, + "Third synced tab should still be focused" + ); + arrowUp(); + is( + syncedTabsLinks[1], + document.activeElement, + "Second synced tab should be focused" + ); + arrowLeft(); + is( + syncedTabsLinks[0], + document.activeElement, + "First synced tab should be focused" + ); + arrowRight(); + is( + syncedTabsLinks[1], + document.activeElement, + "Second synced tab should be focused" + ); + arrowDown(); + is( + syncedTabsLinks[2], + document.activeElement, + "Third synced tab should be focused" + ); + arrowLeft(); + is( + syncedTabsLinks[0], + document.activeElement, + "First synced tab should be focused" + ); + + tab(true); + is( + summary, + document.activeElement, + "Summary element should be focused when shift tabbing away from list" + ); + + sandbox.restore(); + cleanup_tab_pickup(); + }); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_ui_state.js b/browser/components/firefoxview/tests/browser/browser_ui_state.js new file mode 100644 index 0000000000..cc19b75023 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_ui_state.js @@ -0,0 +1,145 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { TabsSetupFlowManager } = ChromeUtils.importESModule( + "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs" +); + +add_task(async function test_state_prefs_unset() { + await SpecialPowers.clearUserPref(TAB_PICKUP_STATE_PREF); + await SpecialPowers.clearUserPref(RECENTLY_CLOSED_STATE_PREF); + + const sandbox = sinon.createSandbox(); + let setupCompleteStub = sandbox.stub( + TabsSetupFlowManager, + "isTabSyncSetupComplete" + ); + setupCompleteStub.returns(true); + + await withFirefoxView({}, async function(browser) { + const { document } = browser.contentWindow; + let recentlyClosedTabsContainer = document.querySelector( + "#recently-closed-tabs-container" + ); + ok( + recentlyClosedTabsContainer.open, + "Recently Closed Tabs should be open if the pref is unset and sync setup is complete" + ); + + let tabPickupContainer = document.querySelector("#tab-pickup-container"); + ok( + tabPickupContainer.open, + "Tab Pickup container should be open if the pref is unset and sync setup is complete" + ); + + sandbox.restore(); + }); +}); + +add_task(async function test_state_prefs_defined() { + await SpecialPowers.pushPrefEnv({ + set: [ + [TAB_PICKUP_STATE_PREF, false], + [RECENTLY_CLOSED_STATE_PREF, false], + ], + }); + + const sandbox = sinon.createSandbox(); + let setupCompleteStub = sandbox.stub( + TabsSetupFlowManager, + "isTabSyncSetupComplete" + ); + setupCompleteStub.returns(true); + + await withFirefoxView({}, async function(browser) { + const { document } = browser.contentWindow; + let recentlyClosedTabsContainer = document.querySelector( + "#recently-closed-tabs-container" + ); + ok( + !recentlyClosedTabsContainer.getAttribute("open"), + "Recently Closed Tabs should not be open if the pref is set to false" + ); + + let tabPickupContainer = document.querySelector("#tab-pickup-container"); + ok( + !tabPickupContainer.getAttribute("open"), + "Tab Pickup container should not be open if the pref is set to false and sync setup is complete" + ); + + sandbox.restore(); + }); +}); + +add_task(async function test_state_pref_set_on_toggle() { + await SpecialPowers.pushPrefEnv({ + set: [ + [TAB_PICKUP_STATE_PREF, true], + [RECENTLY_CLOSED_STATE_PREF, true], + ], + }); + + const sandbox = sinon.createSandbox(); + let setupCompleteStub = sandbox.stub( + TabsSetupFlowManager, + "isTabSyncSetupComplete" + ); + setupCompleteStub.returns(true); + + await withFirefoxView({}, async function(browser) { + const { document } = browser.contentWindow; + + await waitForElementVisible(browser, "#tab-pickup-container > summary"); + + document.querySelector("#tab-pickup-container > summary").click(); + + document.querySelector("#recently-closed-tabs-container > summary").click(); + + // Wait a turn for the click to propagate to the pref. + await TestUtils.waitForTick(); + + ok( + !Services.prefs.getBoolPref(RECENTLY_CLOSED_STATE_PREF), + "Hiding the recently closed container should have flipped the UI state pref value" + ); + ok( + !Services.prefs.getBoolPref(TAB_PICKUP_STATE_PREF), + "Hiding the tab pickup container should have flipped the UI state pref value" + ); + + sandbox.restore(); + }); +}); + +add_task(async function test_state_prefs_ignored_during_sync_setup() { + await SpecialPowers.pushPrefEnv({ + set: [ + [TAB_PICKUP_STATE_PREF, false], + [RECENTLY_CLOSED_STATE_PREF, false], + ], + }); + const sandbox = sinon.createSandbox(); + let setupCompleteStub = sandbox.stub( + TabsSetupFlowManager, + "isTabSyncSetupComplete" + ); + setupCompleteStub.returns(false); + await withFirefoxView({}, async function(browser) { + const { document } = browser.contentWindow; + let recentlyClosedTabsContainer = document.querySelector( + "#recently-closed-tabs-container" + ); + ok( + !recentlyClosedTabsContainer.open, + "Recently Closed Tabs should not be open if the pref is set to false" + ); + + let tabPickupContainer = document.querySelector("#tab-pickup-container"); + ok( + tabPickupContainer.open, + "Tab Pickup container should be open if the pref is set to false but sync setup is not complete" + ); + + sandbox.restore(); + }); +}); diff --git a/browser/components/firefoxview/tests/browser/head.js b/browser/components/firefoxview/tests/browser/head.js new file mode 100644 index 0000000000..68bf9b8316 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/head.js @@ -0,0 +1,599 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* exported testVisibility */ +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +const { ASRouter } = ChromeUtils.import( + "resource://activity-stream/lib/ASRouter.jsm" +); +const { UIState } = ChromeUtils.import("resource://services-sync/UIState.jsm"); +const { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm"); +const { FeatureCalloutMessages } = ChromeUtils.import( + "resource://activity-stream/lib/FeatureCalloutMessages.jsm" +); +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + AboutWelcomeParent: "resource:///actors/AboutWelcomeParent.jsm", +}); + +const MOBILE_PROMO_DISMISSED_PREF = + "browser.tabs.firefox-view.mobilePromo.dismissed"; +const RECENTLY_CLOSED_STATE_PREF = + "browser.tabs.firefox-view.ui-state.recently-closed-tabs.open"; +const TAB_PICKUP_STATE_PREF = + "browser.tabs.firefox-view.ui-state.tab-pickup.open"; + +const calloutId = "root"; +const calloutSelector = `#${calloutId}.featureCallout`; +const primaryButtonSelector = `#${calloutId} .primary`; + +/** + * URLs used for browser_recently_closed_tabs_keyboard and + * browser_firefoxview_accessibility + */ +const URLs = [ + "http://mochi.test:8888/browser/", + "https://www.example.com/", + "https://example.net/", + "https://example.org/", +]; + +const syncedTabsData1 = [ + { + id: 1, + type: "client", + name: "My desktop", + clientType: "desktop", + lastModified: 1655730486760, + tabs: [ + { + type: "tab", + title: "Sandboxes - Sinon.JS", + url: "https://sinonjs.org/releases/latest/sandbox/", + icon: "https://sinonjs.org/assets/images/favicon.png", + lastUsed: 1655391592, // Thu Jun 16 2022 14:59:52 GMT+0000 + }, + { + type: "tab", + title: "Internet for people, not profits - Mozilla", + url: "https://www.mozilla.org/", + icon: + "https://www.mozilla.org/media/img/favicons/mozilla/favicon.d25d81d39065.ico", + lastUsed: 1655730486, // Mon Jun 20 2022 13:08:06 GMT+0000 + }, + ], + }, + { + id: 2, + type: "client", + name: "My iphone", + clientType: "phone", + lastModified: 1655727832930, + tabs: [ + { + type: "tab", + title: "The Guardian", + url: "https://www.theguardian.com/", + icon: "page-icon:https://www.theguardian.com/", + lastUsed: 1655291890, // Wed Jun 15 2022 11:18:10 GMT+0000 + }, + { + type: "tab", + title: "The Times", + url: "https://www.thetimes.co.uk/", + icon: "page-icon:https://www.thetimes.co.uk/", + lastUsed: 1655727485, // Mon Jun 20 2022 12:18:05 GMT+0000 + }, + ], + }, +]; + +async function clearAllParentTelemetryEvents() { + // Clear everything. + await TestUtils.waitForCondition(() => { + Services.telemetry.clearEvents(); + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).parent; + return !events || !events.length; + }); +} + +function testVisibility(browser, expected) { + const { document } = browser.contentWindow; + for (let [selector, shouldBeVisible] of Object.entries( + expected.expectedVisible + )) { + const elem = document.querySelector(selector); + if (shouldBeVisible) { + ok( + BrowserTestUtils.is_visible(elem), + `Expected ${selector} to be visible` + ); + } else { + ok(BrowserTestUtils.is_hidden(elem), `Expected ${selector} to be hidden`); + } + } +} + +async function waitForElementVisible(browser, selector, isVisible = true) { + const { document } = browser.contentWindow; + const elem = document.querySelector(selector); + if (!isVisible && !elem) { + return; + } + ok(elem, `Got element with selector: ${selector}`); + + await BrowserTestUtils.waitForMutationCondition( + elem, + { + attributeFilter: ["hidden"], + }, + () => { + return isVisible + ? BrowserTestUtils.is_visible(elem) + : BrowserTestUtils.is_hidden(elem); + } + ); +} + +async function waitForVisibleSetupStep(browser, expected) { + const { document } = browser.contentWindow; + + const deck = document.querySelector(".sync-setup-container"); + const nextStepElem = deck.querySelector(expected.expectedVisible); + const stepElems = deck.querySelectorAll(".setup-step"); + + await BrowserTestUtils.waitForMutationCondition( + deck, + { + attributeFilter: ["selected-view"], + }, + () => { + return BrowserTestUtils.is_visible(nextStepElem); + } + ); + + for (let elem of stepElems) { + if (elem == nextStepElem) { + ok( + BrowserTestUtils.is_visible(elem), + `Expected ${elem.id || elem.className} to be visible` + ); + } else { + ok( + BrowserTestUtils.is_hidden(elem), + `Expected ${elem.id || elem.className} to be hidden` + ); + } + } +} + +function assertFirefoxViewTab(w) { + ok(w.FirefoxViewHandler.tab, "Firefox View tab exists"); + ok(w.FirefoxViewHandler.tab?.hidden, "Firefox View tab is hidden"); + is( + w.gBrowser.visibleTabs.indexOf(w.FirefoxViewHandler.tab), + -1, + "Firefox View tab is not in the list of visible tabs" + ); +} + +async function openFirefoxViewTab(w) { + ok( + !w.FirefoxViewHandler.tab, + "Firefox View tab doesn't exist prior to clicking the button" + ); + info("Clicking the Firefox View button"); + await EventUtils.synthesizeMouseAtCenter( + w.document.getElementById("firefox-view-button"), + { type: "mousedown" }, + w + ); + assertFirefoxViewTab(w); + ok(w.FirefoxViewHandler.tab.selected, "Firefox View tab is selected"); + await BrowserTestUtils.browserLoaded(w.FirefoxViewHandler.tab.linkedBrowser); + return w.FirefoxViewHandler.tab; +} + +function closeFirefoxViewTab(w) { + w.gBrowser.removeTab(w.FirefoxViewHandler.tab); + ok( + !w.FirefoxViewHandler.tab, + "Reference to Firefox View tab got removed when closing the tab" + ); +} + +async function withFirefoxView( + { resetFlowManager = true, win = null }, + taskFn +) { + let shouldCloseWin = false; + if (!win) { + win = await BrowserTestUtils.openNewBrowserWindow(); + shouldCloseWin = true; + } + if (resetFlowManager) { + const { TabsSetupFlowManager } = ChromeUtils.importESModule( + "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs" + ); + // reset internal state so we aren't reacting to whatever state the last invocation left behind + TabsSetupFlowManager.resetInternalState(); + } + let tab = await openFirefoxViewTab(win); + let originalWindow = tab.ownerGlobal; + let result = await taskFn(tab.linkedBrowser); + let finalWindow = tab.ownerGlobal; + if (originalWindow == finalWindow && !tab.closing && tab.linkedBrowser) { + // taskFn may resolve within a tick after opening a new tab. + // We shouldn't remove the newly opened tab in the same tick. + // Wait for the next tick here. + await TestUtils.waitForTick(); + BrowserTestUtils.removeTab(tab); + } else { + Services.console.logStringMessage( + "withFirefoxView: Tab was already closed before " + + "removeTab would have been called" + ); + } + if (shouldCloseWin) { + await BrowserTestUtils.closeWindow(win); + } + return result; +} + +var gMockFxaDevices = null; +var gUIStateStatus; +var gSandbox; +function setupSyncFxAMocks({ fxaDevices = null, state, syncEnabled = true }) { + gUIStateStatus = state || UIState.STATUS_SIGNED_IN; + if (gSandbox) { + gSandbox.restore(); + } + const sandbox = (gSandbox = sinon.createSandbox()); + gMockFxaDevices = fxaDevices; + sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => fxaDevices); + sandbox.stub(UIState, "get").callsFake(() => { + return { + status: gUIStateStatus, + syncEnabled, + email: + gUIStateStatus === UIState.STATUS_NOT_CONFIGURED + ? undefined + : "email@example.com", + }; + }); + + return sandbox; +} + +function setupRecentDeviceListMocks() { + const sandbox = sinon.createSandbox(); + sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => [ + { + id: 1, + name: "My desktop", + isCurrentDevice: true, + type: "desktop", + }, + { + id: 2, + name: "My iphone", + type: "mobile", + }, + ]); + + sandbox.stub(UIState, "get").returns({ + status: UIState.STATUS_SIGNED_IN, + syncEnabled: true, + email: "email@example.com", + }); + + return sandbox; +} + +function getMockTabData(clients) { + let tabs = []; + + for (let client of clients) { + for (let tab of client.tabs) { + tab.device = client.name; + tab.deviceType = client.clientType; + } + tabs = [...tabs, ...client.tabs.reverse()]; + } + tabs = tabs.sort((a, b) => b.lastUsed - a.lastUsed).slice(0, 3); + + return tabs; +} + +async function setupListState(browser) { + // Skip the synced tabs sign up flow to get to a loaded list state + await SpecialPowers.pushPrefEnv({ + set: [["services.sync.engine.tabs", true]], + }); + + UIState.refresh(); + const recentFetchTime = Math.floor(Date.now() / 1000); + info("updating lastFetch:" + recentFetchTime); + Services.prefs.setIntPref("services.sync.lastTabFetch", recentFetchTime); + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + await waitForElementVisible(browser, "#tabpickup-steps", false); + await waitForElementVisible(browser, "#tabpickup-tabs-container", true); + + const tabsContainer = browser.contentWindow.document.querySelector( + "#tabpickup-tabs-container" + ); + await tabsContainer.tabListAdded; + await BrowserTestUtils.waitForMutationCondition( + tabsContainer, + { attributeFilter: ["class"], attributes: true }, + () => { + return !tabsContainer.classList.contains("loading"); + } + ); + info("tabsContainer isn't loading anymore, returning"); +} + +function checkMobilePromo(browser, expected = {}) { + const { document } = browser.contentWindow; + const promoElem = document.querySelector( + "#tab-pickup-container > .promo-box" + ); + const successElem = document.querySelector( + "#tab-pickup-container > .confirmation-message-box" + ); + + info("checkMobilePromo: " + JSON.stringify(expected)); + if (expected.mobilePromo) { + ok(BrowserTestUtils.is_visible(promoElem), "Mobile promo is visible"); + } else { + ok( + !promoElem || BrowserTestUtils.is_hidden(promoElem), + "Mobile promo is hidden" + ); + } + if (expected.mobileConfirmation) { + ok( + BrowserTestUtils.is_visible(successElem), + "Success confirmation is visible" + ); + } else { + ok( + !successElem || BrowserTestUtils.is_hidden(successElem), + "Success confirmation is hidden" + ); + } +} + +async function touchLastTabFetch() { + // lastTabFetch stores a timestamp in *seconds*. + const nowSeconds = Math.floor(Date.now() / 1000); + info("updating lastFetch:" + nowSeconds); + Services.prefs.setIntPref("services.sync.lastTabFetch", nowSeconds); + // wait so all pref observers can complete + await TestUtils.waitForTick(); +} + +let gUIStateSyncEnabled; +function setupMocks({ fxaDevices = null, state, syncEnabled = true }) { + gUIStateStatus = state || UIState.STATUS_SIGNED_IN; + gUIStateSyncEnabled = syncEnabled; + if (gSandbox) { + gSandbox.restore(); + } + const sandbox = (gSandbox = sinon.createSandbox()); + gMockFxaDevices = fxaDevices; + sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => fxaDevices); + sandbox.stub(UIState, "get").callsFake(() => { + return { + status: gUIStateStatus, + // Sometimes syncEnabled is not present on UIState, for example when the user signs + // out the state is just { status: "not_configured" } + ...(gUIStateSyncEnabled != undefined && { + syncEnabled: gUIStateSyncEnabled, + }), + }; + }); + return sandbox; +} + +async function tearDown(sandbox) { + sandbox?.restore(); + Services.prefs.clearUserPref("services.sync.lastTabFetch"); + Services.prefs.clearUserPref(MOBILE_PROMO_DISMISSED_PREF); +} + +/** + * Returns a value that can be used to set + * `browser.firefox-view.feature-tour` to change the feature tour's + * UI state. + * + * @see FeatureCalloutMessages.jsm for valid values of "screen" + * + * @param {number} screen The full ID of the feature callout screen + * @return {string} JSON string used to set + * `browser.firefox-view.feature-tour` + */ +const getPrefValueByScreen = screen => { + return JSON.stringify({ + screen: `FEATURE_CALLOUT_${screen}`, + complete: false, + }); +}; + +/** + * Wait for a feature callout screen of given parameters to be shown + * @param {Document} doc the document where the callout appears. + * @param {String} screenPostfix The full ID of the feature callout screen. + */ +const waitForCalloutScreen = async (doc, screenPostfix) => { + await BrowserTestUtils.waitForCondition(() => + doc.querySelector(`${calloutSelector}:not(.hidden) .${screenPostfix}`) + ); +}; + +/** + * Waits for the feature callout screen to be removed. + * + * @param {Document} doc The document where the callout appears. + */ +const waitForCalloutRemoved = async doc => { + await BrowserTestUtils.waitForCondition(() => { + return !doc.body.querySelector(calloutSelector); + }); +}; + +/** + * NOTE: Should be replaced with synthesizeMouseAtCenter for + * simulating user input. See Bug 1798322 + * + * Clicks the primary button in the feature callout dialog + * + * @param {document} doc Firefox View document + */ +const clickPrimaryButton = async doc => { + doc.querySelector(primaryButtonSelector).click(); +}; + +/** + * Closes a feature callout via a click to the dismiss button. + * + * @param {Document} doc The document where the callout appears. + */ +const closeCallout = async doc => { + // close the callout dialog + const dismissBtn = doc.querySelector(`${calloutSelector} .dismiss-button`); + if (!dismissBtn) { + return; + } + doc.querySelector(`${calloutSelector} .dismiss-button`).click(); + await BrowserTestUtils.waitForCondition(() => { + return !document.querySelector(calloutSelector); + }); +}; + +/** + * Get a Feature Callout message by id. + * + * @param {string} Message id + */ +const getCalloutMessageById = id => { + return { + message: FeatureCalloutMessages.getMessages().find(m => m.id === id), + }; +}; + +/** + * Create a sinon sandbox with `sendTriggerMessage` stubbed + * to return a specified test message for featureCalloutCheck. + * + * @param {object} Test message + */ +const createSandboxWithCalloutTriggerStub = testMessage => { + const firefoxViewMatch = sinon.match({ + id: "featureCalloutCheck", + context: { source: "firefoxview" }, + }); + const sandbox = sinon.createSandbox(); + const sendTriggerStub = sandbox.stub(ASRouter, "sendTriggerMessage"); + sendTriggerStub.withArgs(firefoxViewMatch).resolves(testMessage); + sendTriggerStub.callThrough(); + return sandbox; +}; + +/** + * A helper to check that correct telemetry was sent by AWSendEventTelemetry. + * This is a wrapper around sinon's spy functionality. + * + * @example + * let spy = new TelemetrySpy(); + * element.click(); + * spy.assertCalledWith({ event: "CLICK" }); + * spy.restore(); + */ +class TelemetrySpy { + /** + * @param {object} [sandbox] A pre-existing sinon sandbox to build the spy in. + * If not provided, a new sandbox will be created. + */ + constructor(sandbox = sinon.createSandbox()) { + this.sandbox = sandbox; + this.spy = this.sandbox + .spy(AboutWelcomeParent.prototype, "onContentMessage") + .withArgs("AWPage:TELEMETRY_EVENT"); + registerCleanupFunction(() => this.restore()); + } + /** + * Assert that AWSendEventTelemetry sent the expected telemetry object. + * @param {Object} expectedData + */ + assertCalledWith(expectedData) { + let match = this.spy.calledWith("AWPage:TELEMETRY_EVENT", expectedData); + if (match) { + ok(true, "Expected telemetry sent"); + } else if (this.spy.called) { + ok( + false, + "Wrong telemetry sent: " + JSON.stringify(this.spy.lastCall.args) + ); + } else { + ok(false, "No telemetry sent"); + } + } + reset() { + this.spy.resetHistory(); + } + restore() { + this.sandbox.restore(); + } +} + +/** + * Helper function to open and close a tab so the recently + * closed tabs list can have data. + * + * @param {string} url + * @return {Promise} Promise that resolves when the session store + * has been updated after closing the tab. + */ +async function open_then_close(url) { + let { updatePromise } = await BrowserTestUtils.withNewTab( + url, + async browser => { + return { + updatePromise: BrowserTestUtils.waitForSessionStoreUpdate({ + linkedBrowser: browser, + }), + }; + } + ); + await updatePromise; + return TestUtils.topicObserved("sessionstore-closed-objects-changed"); +} + +/** + * Clears session history. Used to clear out the recently closed tabs list. + * + */ +function clearHistory() { + Services.obs.notifyObservers(null, "browser:purge-session-history"); +} + +/** + * Cleanup function for tab pickup tests. + * + */ +function cleanup_tab_pickup() { + Services.prefs.clearUserPref("services.sync.engine.tabs"); + Services.prefs.clearUserPref("services.sync.lastTabFetch"); + Services.prefs.clearUserPref(TAB_PICKUP_STATE_PREF); +} |