diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /browser/components/firefoxview | |
parent | Initial commit. (diff) | |
download | firefox-esr-upstream.tar.xz firefox-esr-upstream.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esrupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/firefoxview')
68 files changed, 13824 insertions, 0 deletions
diff --git a/browser/components/firefoxview/card-container.css b/browser/components/firefoxview/card-container.css new file mode 100644 index 0000000000..982b33c4bd --- /dev/null +++ b/browser/components/firefoxview/card-container.css @@ -0,0 +1,125 @@ +/* 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/. */ + +.card-container { + padding: 8px; + border-radius: 8px; + background-color: var(--fxview-background-color-secondary); + margin-block-end: 24px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); +} + +@media (prefers-contrast) { + .card-container { + border: 1px solid CanvasText; + } +} + +.card-container-header { + display: inline-flex; + gap: 16px; + width: 100%; + align-items: center; + cursor: pointer; + border-radius: 1px; + outline-offset: 6px; +} + +.card-container-header[withViewAll] { + width: 85%; +} + +.card-container-header[hidden] { + display: none; +} + +.view-all-link { + color: var(--fxview-primary-action-background); + float: inline-end; + outline-offset: 8px; + border-radius: 1px; + width: 12%; + text-align: end; +} + +.card-container-header:focus-visible, +.view-all-link:focus-visible { + outline: 2px solid var(--in-content-focus-outline-color); +} + +.chevron-icon { + background-image: url("chrome://global/skin/icons/arrow-up.svg"); + padding: 2px; + display: inline-block; + justify-self: start; + fill: currentColor; + margin-block: 0; + width: 16px; + height: 16px; + background-position: center; + -moz-context-properties: fill; + border: none; + background-color: transparent; + background-repeat: no-repeat; + border-radius: 4px; +} + +.chevron-icon:hover { + background-color: var(--fxview-element-background-hover); +} + +@media (prefers-contrast) { + .chevron-icon { + border: 1px solid ButtonText; + color: ButtonText; + } + + .chevron-icon:hover { + border: 1px solid SelectedItem; + color: SelectedItem; + } + + .chevron-icon:active { + color: SelectedItem; + } + + .chevron-icon, + .chevron-icon:hover, + .chevron-icon:active { + background-color: ButtonFace; + } +} + +.card-container:not([open]) .chevron-icon { + background-image: url("chrome://global/skin/icons/arrow-down.svg"); +} + +.card-container:not([open]) a { + display: none; +} + +::slotted(h2) { + margin: 0; + font-size: 1.13em; + font-weight: 600; +} + +.card-container-footer { + text-align: center; + color: var(--fxview-primary-action-background); + cursor: pointer; +} + +::slotted([slot=footer]) { + text-decoration: underline; +} + +@media (max-width: 39rem) { + .card-container-header[withViewAll] { + width: 77%; + } + .view-all-link { + width: 20%; + } +} diff --git a/browser/components/firefoxview/card-container.mjs b/browser/components/firefoxview/card-container.mjs new file mode 100644 index 0000000000..6d2fa0c600 --- /dev/null +++ b/browser/components/firefoxview/card-container.mjs @@ -0,0 +1,93 @@ +/* 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/. */ + +import { html, ifDefined } from "chrome://global/content/vendor/lit.all.mjs"; +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; + +/** + * A collapsible card container to be used throughout Firefox View + * + * @property {string} sectionLabel - The aria-label used for the section landmark if the header is hidden with hideHeader + * @property {boolean} hideHeader - Optional property given if the card container should not display a header + * @property {boolean} preserveCollapseState - Whether or not the expanded/collapsed state should persist + * @property {string} viewAllPage - The location hash for the 'View all' header link to navigate to + */ +class CardContainer extends MozLitElement { + constructor() { + super(); + this.isExpanded = true; + } + + static properties = { + sectionLabel: { type: String }, + hideHeader: { type: Boolean }, + preserveCollapseState: { type: Boolean }, + viewAllPage: { type: String }, + }; + + static queries = { + detailsEl: "details", + summaryEl: "summary", + viewAllLink: ".view-all-link", + }; + + get detailsExpanded() { + return this.detailsEl.hasAttribute("open"); + } + + connectedCallback() { + super.connectedCallback(); + if (this.preserveCollapseState && this.viewAllPage) { + this.openStatePref = `browser.tabs.firefox-view.ui-state.${this.viewAllPage}.open`; + this.isExpanded = Services.prefs.getBoolPref(this.openStatePref, true); + } + } + + disconnectedCallback() {} + + onToggleContainer() { + this.isExpanded = this.detailsExpanded; + if (this.preserveCollapseState && this.viewAllPage) { + Services.prefs.setBoolPref(this.openStatePref, this.isExpanded); + } + } + + render() { + return html` + <link + rel="stylesheet" + href="chrome://browser/content/firefoxview/card-container.css" + /> + <section + aria-labelledby="header" + aria-label=${ifDefined(this.sectionLabel)} + > + <details + class="card-container" + ?open=${this.isExpanded} + @toggle=${this.onToggleContainer} + > + <summary + id="header" + class="card-container-header" + ?hidden=${ifDefined(this.hideHeader)} + ?withViewAll=${ifDefined(this.viewAllPage)} + > + <span class="icon chevron-icon" aria-role="presentation"></span> + <slot name="header"></slot> + </summary> + <a + href="about:firefoxview-next#${this.viewAllPage}" + class="view-all-link" + data-l10n-id="firefoxview-view-all-link" + ?hidden=${!this.viewAllPage} + ></a> + <slot name="main"></slot> + <slot name="footer" class="card-container-footer"></slot> + </details> + </section> + `; + } +} +customElements.define("card-container", CardContainer); 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..0d0d1375ef --- /dev/null +++ b/browser/components/firefoxview/firefox-view-notification-manager.sys.mjs @@ -0,0 +1,115 @@ +/* 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"; + +ChromeUtils.defineESModuleGetters(lazy, { + SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.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-synced-tabs-error-handler.sys.mjs b/browser/components/firefoxview/firefox-view-synced-tabs-error-handler.sys.mjs new file mode 100644 index 0000000000..414a0dab78 --- /dev/null +++ b/browser/components/firefoxview/firefox-view-synced-tabs-error-handler.sys.mjs @@ -0,0 +1,187 @@ +/* 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 SyncedTabsErrorHandler singleton, which handles + * error states for synced tabs. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + UIState: "resource://services-sync/UIState.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "syncUtils", () => { + return ChromeUtils.importESModule("resource://services-sync/util.sys.mjs") + .Utils; +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gNetworkLinkService", + "@mozilla.org/network/network-link-service;1", + "nsINetworkLinkService" +); + +const FXA_ENABLED = "identity.fxaccounts.enabled"; +const NETWORK_STATUS_CHANGED = "network:offline-status-changed"; +const SYNC_SERVICE_ERROR = "weave:service:sync:error"; +const SYNC_SERVICE_FINISHED = "weave:service:sync:finish"; +const TOPIC_DEVICESTATE_CHANGED = "firefox-view.devicestate.changed"; + +const ErrorType = Object.freeze({ + SYNC_ERROR: "sync-error", + FXA_ADMIN_DISABLED: "fxa-admin-disabled", + NETWORK_OFFLINE: "network-offline", + SYNC_DISCONNECTED: "sync-disconnected", + PASSWORD_LOCKED: "password-locked", + SIGNED_OUT: "signed-out", +}); + +export const SyncedTabsErrorHandler = { + init() { + this.networkIsOnline = + lazy.gNetworkLinkService.linkStatusKnown && + lazy.gNetworkLinkService.isLinkUp; + this.syncIsConnected = lazy.UIState.get().syncEnabled; + this.syncIsWorking = true; + + Services.obs.addObserver(this, NETWORK_STATUS_CHANGED); + Services.obs.addObserver(this, lazy.UIState.ON_UPDATE); + Services.obs.addObserver(this, SYNC_SERVICE_ERROR); + Services.obs.addObserver(this, SYNC_SERVICE_FINISHED); + Services.obs.addObserver(this, TOPIC_DEVICESTATE_CHANGED); + + return this; + }, + + 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 + ); + }, + + getErrorType() { + // this ordering is important for dealing with multiple errors at once + const errorStates = { + [ErrorType.NETWORK_OFFLINE]: !this.networkIsOnline, + [ErrorType.FXA_ADMIN_DISABLED]: Services.prefs.prefIsLocked(FXA_ENABLED), + [ErrorType.PASSWORD_LOCKED]: this.isPrimaryPasswordLocked, + [ErrorType.SIGNED_OUT]: + lazy.UIState.get().status === lazy.UIState.STATUS_LOGIN_FAILED, + [ErrorType.SYNC_DISCONNECTED]: !this.syncIsConnected, + [ErrorType.SYNC_ERROR]: !this.syncIsWorking && !this.syncHasWorked, + }; + + for (let [type, value] of Object.entries(errorStates)) { + if (value) { + return type; + } + } + return null; + }, + + getFluentStringsForErrorType(type) { + return Object.freeze(this._errorStateStringMappings[type]); + }, + + get isPrimaryPasswordLocked() { + return lazy.syncUtils.mpLocked(); + }, + + isSyncReady() { + 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) + ); + }, + + observe(_, topic, data) { + switch (topic) { + case NETWORK_STATUS_CHANGED: + this.networkIsOnline = data == "online"; + break; + case lazy.UIState.ON_UPDATE: + this.syncIsConnected = lazy.UIState.get().syncEnabled; + break; + case SYNC_SERVICE_ERROR: + if (lazy.UIState.get().status == lazy.UIState.STATUS_SIGNED_IN) { + this.syncIsWorking = false; + } + break; + case SYNC_SERVICE_FINISHED: + if (!this.syncIsWorking) { + this.syncIsWorking = true; + this.syncHasWorked = true; + } + break; + case TOPIC_DEVICESTATE_CHANGED: + this.syncHasWorked = false; + } + }, + + ErrorType, + + // 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. + _errorStateStringMappings: { + [ErrorType.SYNC_ERROR]: { + header: "firefoxview-tabpickup-sync-error-header", + description: "firefoxview-tabpickup-generic-sync-error-description", + buttonLabel: "firefoxview-tabpickup-sync-error-primarybutton", + }, + [ErrorType.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. + }, + [ErrorType.NETWORK_OFFLINE]: { + header: "firefoxview-tabpickup-network-offline-header", + description: "firefoxview-tabpickup-network-offline-description", + buttonLabel: "firefoxview-tabpickup-network-offline-primarybutton", + }, + [ErrorType.SYNC_DISCONNECTED]: { + header: "firefoxview-tabpickup-sync-disconnected-header", + description: "firefoxview-tabpickup-sync-disconnected-description", + buttonLabel: "firefoxview-tabpickup-sync-disconnected-primarybutton", + }, + [ErrorType.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", + }, + }, + [ErrorType.SIGNED_OUT]: { + header: "firefoxview-tabpickup-signed-out-header", + description: "firefoxview-tabpickup-signed-out-description", + buttonLabel: "firefoxview-tabpickup-signed-out-primarybutton", + }, + }, +}.init(); 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..3696ae17b1 --- /dev/null +++ b/browser/components/firefoxview/firefox-view-tabs-setup-manager.sys.mjs @@ -0,0 +1,667 @@ +/* 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 { PromiseUtils } from "resource://gre/modules/PromiseUtils.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Log: "resource://gre/modules/Log.sys.mjs", + SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs", + SyncedTabsErrorHandler: + "resource:///modules/firefox-view-synced-tabs-error-handler.sys.mjs", + UIState: "resource://services-sync/UIState.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "syncUtils", () => { + return ChromeUtils.importESModule("resource://services-sync/util.sys.mjs") + .Utils; +}); + +XPCOMUtils.defineLazyGetter(lazy, "fxAccounts", () => { + return ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" + ).getFxAccountsSingleton(); +}); + +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_DEVICESTATE_CHANGED = "firefox-view.devicestate.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_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.syncIsConnected = lazy.UIState.get().syncEnabled; + this.didFxaTabOpen = false; + + this.registerSetupState({ + uiStateIndex: 0, + name: "error-state", + exitConditions: () => { + return lazy.SyncedTabsErrorHandler.isSyncReady(); + }, + }); + 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.abortWaitingForTabs(); + + Services.obs.notifyObservers(null, TOPIC_DEVICESTATE_CHANGED); + + // keep track of what is connected so we can respond to changes + this._deviceStateSnapshot = { + mobileDeviceConnected: this.mobileDeviceConnected, + secondaryDeviceConnected: this.secondaryDeviceConnected, + }; + // keep track of tab-pickup-container instance visibilities + this._viewVisibilityStates = new Map(); + } + + get isPrimaryPasswordLocked() { + return lazy.syncUtils.mpLocked(); + } + + 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 hasVisibleViews() { + return Array.from(this._viewVisibilityStates.values()).reduce( + (hasVisible, visibility) => { + return hasVisible || visibility == "visible"; + }, + false + ); + } + 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 { + await this.maybeUpdateUI(); + } + this._lastFxASignedIn = this.fxaSignedIn; + break; + case TOPIC_DEVICELIST_UPDATED: + this.logger.debug("Handling observer notification:", topic, data); + const { deviceStateChanged, deviceAdded } = await this.refreshDevices(); + if (deviceStateChanged) { + await this.maybeUpdateUI(true); + } + if (deviceAdded && this.secondaryDeviceConnected) { + this.logger.debug("device was added"); + this._deviceAddedResultsNeverSeen = true; + if (this.hasVisibleViews) { + this.startWaitingForNewDeviceTabs(); + } + } + break; + case FXA_DEVICE_CONNECTED: + case FXA_DEVICE_DISCONNECTED: + await lazy.fxAccounts.device.refreshDeviceList({ ignoreCached: true }); + await 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.abortWaitingForTabs(); + await this.maybeUpdateUI(true); + } + break; + case NETWORK_STATUS_CHANGED: + this.abortWaitingForTabs(); + await this.maybeUpdateUI(true); + break; + case SYNC_SERVICE_FINISHED: + this.logger.debug(`Handling ${SYNC_SERVICE_FINISHED}`); + // We intentionally leave any empty-tabs timestamp + // as we may be still waiting for a sync that delivers some tabs + this._waitingForNextTabSync = false; + await 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; + } + } + + updateViewVisibility(instanceId, visibility) { + const wasVisible = this.hasVisibleViews; + this.logger.debug( + `updateViewVisibility for instance: ${instanceId}, visibility: ${visibility}` + ); + if (visibility == "unloaded") { + this._viewVisibilityStates.delete(instanceId); + } else { + this._viewVisibilityStates.set(instanceId, visibility); + } + const isVisible = this.hasVisibleViews; + if (isVisible && !wasVisible) { + // If we're already timing waiting for tabs from a newly-added device + // we might be able to stop + if (this._noTabsVisibleFromAddedDeviceTimestamp) { + return this.stopWaitingForNewDeviceTabs(); + } + if (this._deviceAddedResultsNeverSeen) { + // If this is the first time a view has been visible since a device was added + // we may want to start the empty-tabs visible timer + return this.startWaitingForNewDeviceTabs(); + } + } + if (!isVisible) { + this.logger.debug( + "Resetting timestamp and tabs pending flags as there are no visible views" + ); + // if there's no view visible, we're not really waiting anymore + this.abortWaitingForTabs(); + } + return null; + } + + get waitingForTabs() { + return ( + // signed in & at least 1 other device is syncing indicates there's something to wait for + this.secondaryDeviceConnected && this._waitingForNextTabSync + ); + } + + abortWaitingForTabs() { + this._waitingForNextTabSync = false; + // also clear out the device-added / tabs pending flags + this._noTabsVisibleFromAddedDeviceTimestamp = 0; + this._deviceAddedResultsNeverSeen = false; + } + + startWaitingForTabs() { + if (!this._waitingForNextTabSync) { + this._waitingForNextTabSync = true; + Services.obs.notifyObservers(null, TOPIC_SETUPSTATE_CHANGED); + } + } + + async stopWaitingForTabs() { + const wasWaiting = this.waitingForTabs; + if (this.hasVisibleViews && this._deviceAddedResultsNeverSeen) { + await this.stopWaitingForNewDeviceTabs(); + } + this._waitingForNextTabSync = false; + if (wasWaiting) { + Services.obs.notifyObservers(null, TOPIC_SETUPSTATE_CHANGED); + } + } + + async onSignedInChange() { + this.logger.debug("onSignedInChange, fxaSignedIn:", this.fxaSignedIn); + // update UI to make the state change + await this.maybeUpdateUI(true); + if (!this.fxaSignedIn) { + // As we just signed out, ensure the waiting flag is reset for next time around + this.abortWaitingForTabs(); + 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 + const { deviceStateChanged } = await this.refreshDevices(); + if (deviceStateChanged) { + this.logger.debug( + "onSignedInChange, after refreshDevices, calling maybeUpdateUI" + ); + // give the UI an opportunity to update as secondaryDeviceConnected or + // mobileDeviceConnected have changed value + await 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 startWaitingForNewDeviceTabs() { + // if we're already waiting for tabs, don't reset + if (this._noTabsVisibleFromAddedDeviceTimestamp) { + return; + } + + // take a timestamp whenever the latest device is added and we have 0 tabs to show, + // allowing us to track how long we show an empty list after a new device is added + const hasRecentTabs = (await lazy.SyncedTabs.getRecentTabs(1)).length; + if (this.hasVisibleViews && !hasRecentTabs) { + this._noTabsVisibleFromAddedDeviceTimestamp = Date.now(); + this.logger.debug( + "New device added with 0 synced tabs to show, storing timestamp:", + this._noTabsVisibleFromAddedDeviceTimestamp + ); + } + } + + async stopWaitingForNewDeviceTabs() { + if (!this._noTabsVisibleFromAddedDeviceTimestamp) { + return; + } + const recentTabs = await lazy.SyncedTabs.getRecentTabs(1); + if (recentTabs.length) { + // We have been waiting for > 0 tabs after a newly-added device, record + // the time elapsed + const elapsed = Date.now() - this._noTabsVisibleFromAddedDeviceTimestamp; + this.logger.debug( + "stopWaitingForTabs, resetting _noTabsVisibleFromAddedDeviceTimestamp and recording telemetry:", + Math.round(elapsed / 1000) + ); + this._noTabsVisibleFromAddedDeviceTimestamp = 0; + this._deviceAddedResultsNeverSeen = false; + Services.telemetry.recordEvent( + "firefoxview", + "synced_tabs_empty", + "since_device_added", + Math.round(elapsed / 1000).toString() + ); + } else { + // we are still waiting for some tabs to show... + this.logger.debug( + "stopWaitingForTabs: Still no recent tabs, we are still waiting" + ); + } + } + + 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; + const oldDevicesCount = this._deviceStateSnapshot?.devicesCount ?? 0; + const devicesCount = lazy.fxAccounts.device?.recentDeviceList?.length ?? 0; + + this.logger.debug( + `refreshDevices, mobileDeviceConnected: ${mobileDeviceConnected}, `, + `secondaryDeviceConnected: ${secondaryDeviceConnected}` + ); + + let deviceStateChanged = + 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, + devicesCount, + }; + if (deviceStateChanged) { + this.logger.debug("refreshDevices: device state did change"); + if (!secondaryDeviceConnected) { + this.logger.debug( + "We lost a device, now claim sync hasn't worked before." + ); + Services.obs.notifyObservers(null, TOPIC_DEVICESTATE_CHANGED); + } + } else { + this.logger.debug("refreshDevices: no device state change"); + } + return { + deviceStateChanged, + deviceAdded: oldDevicesCount < devicesCount, + }; + } + + async 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) { + // Use idleDispatch() to give observers a chance to resolve before + // determining the new state. + errorState = await PromiseUtils.idleDispatch(() => + lazy.SyncedTabsErrorHandler.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(); + if (this.isPrimaryPasswordLocked) { + lazy.syncUtils.ensureMPUnlocked(); + } + this.logger.debug("tryToClearError: triggering new tab sync"); + this.syncTabs(); + Services.tm.dispatchToMainThread(() => {}); + } 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); + } +})(); diff --git a/browser/components/firefoxview/firefoxview-next.css b/browser/components/firefoxview/firefoxview-next.css new file mode 100644 index 0000000000..0b6e588c21 --- /dev/null +++ b/browser/components/firefoxview/firefoxview-next.css @@ -0,0 +1,86 @@ +/* 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/. */ + + @import url("chrome://global/skin/in-content/common.css"); + +:root { + /* override --in-content-page-background from common-shared.css */ + background-color: transparent; + --fxview-background-color: var(--newtab-background-color, var(--in-content-page-background)); + --fxview-background-color-secondary: var(--newtab-background-color-secondary, #FFFFFF); + --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-primary-action-background: var(--newtab-primary-action-background, #0061e0); + + /* 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); + + --fxview-sidebar-width: 286px; + --fxview-margin-top: 72px; +} + +@media (prefers-color-scheme: dark) { + :root { + --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; + --newtab-primary-action-background: #00ddff; + } +} + +@media (prefers-contrast) { + :root { + --fxview-element-background-hover: ButtonText; + --fxview-element-background-active: ButtonText; + --fxview-text-color-hover: ButtonFace; + --newtab-primary-action-background: LinkText; + --newtab-background-color-secondary: Canvas; + } +} + +body { + display: grid; + grid-template-columns: var(--fxview-sidebar-width) 1fr; + background-color: var(--fxview-background-color); + color: var(--fxview-text-primary-color); + margin-block-start: var(--fxview-margin-top); +} + +#pages { + width: 90%; + margin: 0 auto; + max-width: 1136px; +} + +fxview-category-button[name="overview"]::part(icon) { + background-image: url("chrome://mozapps/skin/extensions/category-discover.svg"); +} +fxview-category-button[name="opentabs"]::part(icon) { + background-image: url("chrome://mozapps/skin/extensions/category-discover.svg"); +} +fxview-category-button[name="recently-closed"]::part(icon) { + background-image: url("chrome://mozapps/skin/extensions/category-discover.svg"); +} +fxview-category-button[name="synced-tabs"]::part(icon) { + background-image: url("chrome://mozapps/skin/extensions/category-discover.svg"); +} +fxview-category-button[name="history"]::part(icon) { + background-image: url("chrome://mozapps/skin/extensions/category-discover.svg"); +} + +@media (max-width: 52rem) { + :root { + --fxview-sidebar-width: 82px; + } +} + +@media (min-width: 120rem) { + #pages { + margin-inline-start: 148px; + } +} diff --git a/browser/components/firefoxview/firefoxview-next.html b/browser/components/firefoxview/firefoxview-next.html new file mode 100644 index 0000000000..72f1e0a357 --- /dev/null +++ b/browser/components/firefoxview/firefoxview-next.html @@ -0,0 +1,96 @@ +<!-- 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="localization" href="browser/firefoxView.ftl" /> + <link rel="localization" href="toolkit/branding/brandings.ftl" /> + <link + rel="stylesheet" + href="chrome://browser/content/firefoxview/firefoxview-next.css" + /> + <script + type="module" + src="chrome://browser/content/firefoxview/overview.mjs" + ></script> + <script + type="module" + src="chrome://browser/content/firefoxview/history.mjs" + ></script> + <script + type="module" + src="chrome://browser/content/firefoxview/opentabs.mjs" + ></script> + <script + type="module" + src="chrome://browser/content/firefoxview/fxview-category-navigation.mjs" + ></script> + <script src="chrome://browser/content/contentTheme.js"></script> + </head> + + <body> + <fxview-category-navigation> + <h2 slot="category-nav-header" data-l10n-id="firefoxview-page-title"></h2> + <fxview-category-button + class="category" + slot="category-button" + name="overview" + data-l10n-id="firefoxview-overview-nav" + > + </fxview-category-button> + <fxview-category-button + class="category" + slot="category-button" + name="opentabs" + data-l10n-id="firefoxview-opentabs-nav" + > + </fxview-category-button> + <fxview-category-button + class="category" + slot="category-button" + name="recently-closed" + data-l10n-id="firefoxview-recently-closed-nav" + > + </fxview-category-button> + <fxview-category-button + class="category" + slot="category-button" + name="synced-tabs" + data-l10n-id="firefoxview-synced-tabs-nav" + > + </fxview-category-button> + <fxview-category-button + class="category" + slot="category-button" + name="history" + data-l10n-id="firefoxview-history-nav" + > + </fxview-category-button> + </fxview-category-navigation> + <main id="pages"> + <named-deck> + <view-overview name="overview"> + <div> + <view-history slot="history"></view-history> + </div> + <h2 data-l10n-id="firefoxview-opentabs-header"></h2> + <div> + <view-opentabs slot="opentabs"></view-opentabs> + </div> + </view-overview> + <view-history name="history"></view-history> + <view-opentabs name="opentabs"></view-opentabs> + </named-deck> + </main> + <script src="chrome://browser/content/firefoxview/firefoxview-next.mjs"></script> + </body> +</html> diff --git a/browser/components/firefoxview/firefoxview-next.mjs b/browser/components/firefoxview/firefoxview-next.mjs new file mode 100644 index 0000000000..2e504ed5fd --- /dev/null +++ b/browser/components/firefoxview/firefoxview-next.mjs @@ -0,0 +1,62 @@ +/* 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/. */ + +let pageList = []; + +function onHashChange() { + changePage(document.location.hash.substring(1)); +} + +function changePage(page) { + let navigation = document.querySelector("fxview-category-navigation"); + let currentCategoryButton; + if (pageList.includes(page)) { + document.querySelector("named-deck").selectedViewName = page; + navigation.currentCategory = page; + // Move focus if activeElement is a category button when changing page + if (navigation.categoryButtons.includes(document.activeElement)) { + currentCategoryButton = navigation.categoryButtons.filter( + categoryButton => categoryButton.name === page + ); + currentCategoryButton[0]?.focus(); + } + } else { + // Select first category if page not found in pageList + document.location.hash = pageList[0]; + navigation.currentCategory = pageList[0]; + // Move focus if activeElement is a category button when changing page + if (navigation.categoryButtons.includes(document.activeElement)) { + currentCategoryButton = navigation.categoryButtons[0]; + currentCategoryButton?.focus(); + } + } +} + +window.addEventListener("DOMContentLoaded", async () => { + if (document.location.hash) { + changePage(document.location.hash.substring(1)); + } + window.addEventListener("hashchange", onHashChange); + let navigation = document.querySelector("fxview-category-navigation"); + for (const item of navigation.categoryButtons) { + pageList.push(item.getAttribute("name")); + } + window.addEventListener("change-category", function (event) { + location.hash = event.target.getAttribute("name"); + }); +}); + +document + .querySelector("named-deck") + .addEventListener("view-changed", async event => { + for (const child of event.target.children) { + if (child.getAttribute("name") == event.target.selectedViewName) { + child.enter(); + } else { + child.exit(); + } + } + }); + +window.addEventListener("unload", () => {}); diff --git a/browser/components/firefoxview/firefoxview.css b/browser/components/firefoxview/firefoxview.css new file mode 100644 index 0000000000..20536e7ac5 --- /dev/null +++ b/browser/components/firefoxview/firefoxview.css @@ -0,0 +1,951 @@ +/* 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; + + --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; + --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); +} + +body { + 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); +} + +:root:not([lwt-newtab]) { + --in-content-zap-gradient: linear-gradient(90deg, #9059FF 0%, #FF4AA2 52.08%, #FFBD4F 100%); +} + +@media (prefers-color-scheme: dark) { + :root { + --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) { + :root { + --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; + } +} + +@media (max-width: 45rem) { + :host, + :root { + --header-spacing: 16px; + --footer-spacing: 16px; + } +} + +@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; +} + +[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; + font-weight: inherit; + font-size: inherit; + line-height: inherit; +} + +.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; + color: currentColor !important; +} + +.closed-tab-li-url { + padding-inline-start: 8px; + text-decoration-line: underline; + grid-column: span 2; + color: var(--fxview-text-secondary-color) !important; +} + +.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; +} + +.synced-tab-li:not(:first-child) .last-active-badge { + display: none; +} + +.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..2c0aa6624b --- /dev/null +++ b/browser/components/firefoxview/firefoxview.html @@ -0,0 +1,341 @@ +<!-- 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/firefoxView.ftl" /> + <link rel="localization" href="toolkit/branding/accounts.ftl" /> + <link rel="localization" href="toolkit/branding/brandings.ftl" /> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" /> + <link + rel="stylesheet" + href="chrome://browser/content/firefoxview/firefoxview.css" + /> + <script + type="module" + src="chrome://browser/content/firefoxview/tab-pickup-container.mjs" + ></script> + <script + type="module" + src="chrome://browser/content/firefoxview/firefoxview.mjs" + ></script> + <script + type="module" + src="chrome://browser/content/firefoxview/recently-closed-tabs.mjs" + ></script> + <script + type="module" + src="chrome://browser/content/firefoxview/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> + <h2 + class="section-description" + data-l10n-id="firefoxview-tabpickup-description" + ></h2> + </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/firefoxview/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> + + <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> + <h2 + class="section-description" + data-l10n-id="firefoxview-closed-tabs-description2" + ></h2> + </summary> + <div + id="collapsible-tabs-container" + id="recently-closed-tabs" + role="region" + aria-labelledby="recently-closed-tabs-header" + > + <recently-closed-tabs-list></recently-closed-tabs-list> + </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..42b107eb23 --- /dev/null +++ b/browser/components/firefoxview/firefoxview.mjs @@ -0,0 +1,43 @@ +/* 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 launchFeatureTour = () => { + let callout = new FeatureCallout({ + win: window, + prefName: "browser.firefox-view.feature-tour", + page: "about:firefoxview", + theme: { preset: "themed-content" }, + }); + 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(); + // 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(); +}); diff --git a/browser/components/firefoxview/fxview-category-button.css b/browser/components/firefoxview/fxview-category-button.css new file mode 100644 index 0000000000..235aa44ebc --- /dev/null +++ b/browser/components/firefoxview/fxview-category-button.css @@ -0,0 +1,92 @@ +/* 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 { + border-radius: 4px; +} + +button { + background-color: initial; + border: 1px solid var(--in-content-primary-button-border-color); + -moz-context-properties: fill, fill-opacity; + fill: currentColor; + display: grid; + grid-template-columns: min-content 1fr; + gap: 12px; + align-items: center; + font-size: inherit; + width: 100%; + font-weight: normal; + border-radius: 4px; + color: inherit; + text-align: start; + transition: background-color 150ms; + padding: var(--fxviewcategorynav-button-padding); +} + +button[selected] { + text-decoration: underline; + color: var(--in-content-accent-color); +} + +button:focus-visible { + outline: var(--focus-outline); + outline-offset: var(--focus-outline-offset); +} + +.category-icon { + background-color: initial; + background-size: 20px; + background-repeat: no-repeat; + background-position: center; + height: 20px; + width: 20px; + -moz-context-properties: fill; + fill: currentColor; +} + +@media (prefers-contrast) { + button { + transition: none; + border-color: ButtonText; + background-color: var(--in-content-button-background); + } + + button:hover { + color: SelectedItem; + } + + button[selected] { + color: SelectedItemText; + background-color: SelectedItem; + border-color: SelectedItem; + } +} + +@media not (prefers-contrast) { + button:hover { + background-color: var(--in-content-button-background-hover); + border-color: var(--in-content-button-border-color-hover); + } +} + +slot { + font-size: 1.13em; + line-height: 1.4; + margin: 0; + padding-inline-start: 0; + user-select: none; +} + +@media (max-width: 52rem) { + button { + grid-template-columns: min-content; + justify-content: center; + margin-inline: 0; + } + + slot { + display: none; + } +} diff --git a/browser/components/firefoxview/fxview-category-navigation.css b/browser/components/firefoxview/fxview-category-navigation.css new file mode 100644 index 0000000000..0f2198e239 --- /dev/null +++ b/browser/components/firefoxview/fxview-category-navigation.css @@ -0,0 +1,64 @@ +/* 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 { + --fxviewcategorynav-button-padding: 8px; + margin-inline-start: 42px; + border-inline-end: 1px solid transparent; + position: sticky; +} + +nav { + height: 100%; + display: grid; + grid-template-rows: min-content 1fr auto; + gap: 25px; +} + +.category-nav-header { + /* Align the header text/icon with the category button icons */ + margin-inline-start: var(--fxviewcategorynav-button-padding); +} + +::slotted(h2) { + font-size: 1.6em; + margin: 0; +} + +.category-nav-buttons, +::slotted(.category-nav-footer) { + display: grid; + grid-template-columns: 1fr; + grid-auto-rows: min-content; + gap: 4px; +} + +@media (prefers-contrast) { + .category-nav-buttons { + gap: 8px; + } +} + +@media (prefers-reduced-motion) { + /* Setting border-inline-end to add clear differentiation between side navigation and main content area */ + :host { + border-inline-end-color: var(--in-content-border-color); + } +} + +@media (max-width: 52rem) { + :host { + grid-template-rows: 1fr auto; + } + + .category-nav-header { + display: none; + } + + .category-nav-buttons, + ::slotted(.category-nav-footer) { + justify-content: center; + grid-template-columns: min-content; + } +} diff --git a/browser/components/firefoxview/fxview-category-navigation.mjs b/browser/components/firefoxview/fxview-category-navigation.mjs new file mode 100644 index 0000000000..a8ac6838f8 --- /dev/null +++ b/browser/components/firefoxview/fxview-category-navigation.mjs @@ -0,0 +1,149 @@ +/* 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/. */ + +import { html } from "chrome://global/content/vendor/lit.all.mjs"; +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; + +export default class FxviewCategoryNavigation extends MozLitElement { + // Use a relative URL in storybook to get faster reloads on style changes. + static stylesheetUrl = window.IS_STORYBOOK + ? "./fxview-category-navigation.css" + : "chrome://browser/content/firefoxview/fxview-category-navigation.css"; + + static properties = { + currentCategory: { type: String }, + }; + + static queries = { + categoryButtonsSlot: "slot[name=category-button]", + }; + + get categoryButtons() { + return this.categoryButtonsSlot + .assignedNodes() + .filter(node => !node.hidden); + } + + onChangeCategory(e) { + this.currentCategory = e.target.name; + } + + handleFocus(e) { + if (e.key == "ArrowDown" || e.key == "ArrowRight") { + e.preventDefault(); + this.focusNextCategory(); + } else if (e.key == "ArrowUp" || e.key == "ArrowLeft") { + e.preventDefault(); + this.focusPreviousCategory(); + } + } + + focusPreviousCategory() { + let categoryButtons = this.categoryButtons; + let currentIndex = categoryButtons.findIndex(b => b.selected); + let prev = categoryButtons[currentIndex - 1]; + if (prev) { + prev.activate(); + prev.focus(); + } + } + + focusNextCategory() { + let categoryButtons = this.categoryButtons; + let currentIndex = categoryButtons.findIndex(b => b.selected); + let next = categoryButtons[currentIndex + 1]; + if (next) { + next.activate(); + next.focus(); + } + } + + render() { + return html` + <link rel="stylesheet" href=${this.constructor.stylesheetUrl} /> + <nav> + <div class="category-nav-header"> + <slot name="category-nav-header"></slot> + </div> + <div + class="category-nav-buttons" + role="tablist" + aria-orientation="vertical" + > + <slot + name="category-button" + @change-category=${this.onChangeCategory} + @keydown=${this.handleFocus} + ></slot> + </div> + <div class="category-nav-footer"> + <slot name="category-nav-footer"></slot> + </div> + </nav> + `; + } + + updated() { + let categorySelected = false; + let assignedCategories = this.categoryButtons; + for (let button of assignedCategories) { + button.selected = button.name == this.currentCategory; + categorySelected = categorySelected || button.selected; + } + if (!categorySelected && assignedCategories.length) { + // Current category has no matching category, reset to the first category. + assignedCategories[0].activate(); + } + } +} +customElements.define("fxview-category-navigation", FxviewCategoryNavigation); + +export class FxviewCategoryButton extends MozLitElement { + // Use a relative URL in storybook to get faster reloads on style changes. + static stylesheetUrl = window.IS_STORYBOOK + ? "./fxview-category-button.css" + : "chrome://browser/content/firefoxview/fxview-category-button.css"; + + static properties = { + selected: { type: Boolean }, + }; + + static queries = { + buttonEl: "button", + }; + + connectedCallback() { + super.connectedCallback(); + this.setAttribute("role", "tab"); + } + + get name() { + return this.getAttribute("name"); + } + + activate() { + this.dispatchEvent( + new CustomEvent("change-category", { + bubbles: true, + composed: true, + }) + ); + } + + render() { + return html` + <link rel="stylesheet" href=${this.constructor.stylesheetUrl} /> + <button tabindex="-1" ?selected=${this.selected} @click=${this.activate}> + <span class="category-icon" part="icon"></span> + <slot></slot> + </button> + `; + } + + updated() { + this.setAttribute("aria-selected", this.selected); + this.setAttribute("tabindex", this.selected ? 0 : -1); + } +} +customElements.define("fxview-category-button", FxviewCategoryButton); diff --git a/browser/components/firefoxview/fxview-tab-list.css b/browser/components/firefoxview/fxview-tab-list.css new file mode 100644 index 0000000000..4f5ac9b8fc --- /dev/null +++ b/browser/components/firefoxview/fxview-tab-list.css @@ -0,0 +1,9 @@ +/* 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/. */ + +.fxview-tab-list { + display: grid; + grid-template-columns: min-content 3fr 2fr 1fr 1fr min-content; + gap: 8px; +} diff --git a/browser/components/firefoxview/fxview-tab-list.mjs b/browser/components/firefoxview/fxview-tab-list.mjs new file mode 100644 index 0000000000..1bc0dcd6a7 --- /dev/null +++ b/browser/components/firefoxview/fxview-tab-list.mjs @@ -0,0 +1,493 @@ +/* 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/. */ + +import { + html, + ifDefined, + styleMap, +} from "chrome://global/content/vendor/lit.all.mjs"; +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; + +const NOW_THRESHOLD_MS = 91000; +const lazy = {}; +let XPCOMUtils; + +if (!window.IS_STORYBOOK) { + XPCOMUtils = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" + ).XPCOMUtils; + XPCOMUtils.defineLazyGetter(lazy, "relativeTimeFormat", () => { + return new Services.intl.RelativeTimeFormat(undefined, { + style: "narrow", + }); + }); + + ChromeUtils.defineESModuleGetters(lazy, { + BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", + PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs", + }); +} + +/** + * A list of clickable tab items + * + * @property {string} dateTimeFormat - Expected format for date and/or time + * @property {string} hasPopup - The aria-haspopup attribute for the secondary action, if required + * @property {number} maxTabsLength - The max number of tabs for the list + * @property {Array} tabItems - Items to show in the tab list + */ +export default class FxviewTabList extends MozLitElement { + constructor() { + super(); + window.MozXULElement.insertFTLIfNeeded("toolkit/branding/brandings.ftl"); + window.MozXULElement.insertFTLIfNeeded("browser/fxviewTabList.ftl"); + this.activeIndex = 0; + this.currentActiveElementId = "fxview-tab-row-main"; + this.hasPopup = null; + this.dateTimeFormat = "relative"; + this.maxTabsLength = 25; + this.tabItems = []; + this.#register(); + } + + static properties = { + activeIndex: { type: Number }, + currentActiveElementId: { type: String }, + dateTimeFormat: { type: String }, + hasPopup: { type: String }, + maxTabsLength: { type: Number }, + tabItems: { type: Array }, + }; + + static queries = { + rowEls: { all: "fxview-tab-row" }, + }; + + willUpdate(changes) { + this.activeIndex = Math.min( + Math.max(this.activeIndex, 0), + this.tabItems.length - 1 + ); + + if (changes.has("dateTimeFormat")) { + if (this.dateTimeFormat == "relative" && !window.IS_STORYBOOK) { + this.intervalID = setInterval( + () => this.onIntervalUpdate(), + this.timeMsPref + ); + } else { + clearInterval(this.intervalID); + } + } + } + + #register() { + if (!window.IS_STORYBOOK) { + XPCOMUtils.defineLazyPreferenceGetter( + this, + "timeMsPref", + "browser.tabs.firefox-view.updateTimeMs", + NOW_THRESHOLD_MS, + (prefName, oldVal, newVal) => { + if (!this.isConnected) { + return; + } + clearInterval(this.intervalID); + this.intervalID = setInterval(() => { + this.onIntervalUpdate(); + }, newVal); + this.requestUpdate(); + } + ); + } + } + + connectedCallback() { + super.connectedCallback(); + if (this.dateTimeFormat === "relative" && !window.IS_STORYBOOK) { + this.intervalID = setInterval( + () => this.onIntervalUpdate(), + this.timeMsPref + ); + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this.intervalID) { + clearInterval(this.intervalID); + } + } + + async getUpdateComplete() { + await super.getUpdateComplete(); + await Promise.all(Array.from(this.rowEls).map(item => item.updateComplete)); + } + + onIntervalUpdate() { + this.requestUpdate(); + Array.from(this.rowEls).forEach(fxviewTabRow => + fxviewTabRow.requestUpdate() + ); + } + + /** + * Focuses the expected element (either the link or button) within fxview-tab-row + * The currently focused/active element ID within a row is stored in this.currentActiveElementId + */ + handleFocusElementInRow(e) { + let fxviewTabRow = e.target; + if (e.code == "ArrowUp") { + // Focus either the link or button of the previous row based on this.currentActiveElementId + e.preventDefault(); + this.focusPrevRow(); + } else if (e.code == "ArrowDown") { + // Focus either the link or button of the next row based on this.currentActiveElementId + e.preventDefault(); + this.focusNextRow(); + } else if (e.code == "ArrowRight") { + // Focus either the link or the button in the current row and + // set this.currentActiveElementId to that element's ID + e.preventDefault(); + if (document.dir == "rtl") { + this.currentActiveElementId = fxviewTabRow.focusLink(); + } else { + this.currentActiveElementId = fxviewTabRow.focusButton(); + } + } else if (e.code == "ArrowLeft") { + // Focus either the link or the button in the current row and + // set this.currentActiveElementId to that element's ID + e.preventDefault(); + if (document.dir == "rtl") { + this.currentActiveElementId = fxviewTabRow.focusButton(); + } else { + this.currentActiveElementId = fxviewTabRow.focusLink(); + } + } + } + + focusPrevRow() { + // Focus link or button of item above + let previousIndex = this.activeIndex - 1; + if (previousIndex >= 0) { + this.rowEls[previousIndex].focus(); + this.activeIndex = previousIndex; + } + } + + focusNextRow() { + // Focus link or button of item below + let nextIndex = this.activeIndex + 1; + if (nextIndex < this.rowEls.length) { + this.rowEls[nextIndex].focus(); + this.activeIndex = nextIndex; + } + } + + // Use a relative URL in storybook to get faster reloads on style changes. + static stylesheetUrl = window.IS_STORYBOOK + ? "./fxview-tab-list.css" + : "chrome://browser/content/firefoxview/fxview-tab-list.css"; + + render() { + this.tabItems = this.tabItems.slice(0, this.maxTabsLength); + const { + activeIndex, + currentActiveElementId, + dateTimeFormat, + hasPopup, + tabItems, + } = this; + return html` + <link rel="stylesheet" href=${this.constructor.stylesheetUrl} /> + <div + id="fxview-tab-list" + class="fxview-tab-list" + role="list" + @keydown=${this.handleFocusElementInRow} + > + ${tabItems.map( + (tabItem, i) => + html` + <fxview-tab-row + exportparts="secondary-button" + ?active=${i == activeIndex} + .hasPopup=${hasPopup} + .currentActiveElementId=${currentActiveElementId} + .dateTimeFormat=${dateTimeFormat} + .favicon=${tabItem.icon} + .primaryL10nId=${tabItem.primaryL10nId} + .primaryL10nArgs=${ifDefined(tabItem.primaryL10nArgs)} + role="listitem" + .secondaryL10nId=${tabItem.secondaryL10nId} + .secondaryL10nArgs=${ifDefined(tabItem.secondaryL10nArgs)} + .tabid=${ifDefined(tabItem.tabid || tabItem.closedId)} + .time=${(tabItem.time || tabItem.closedAt).toString().length === + 16 + ? (tabItem.time || tabItem.closedAt) / 1000 + : tabItem.time || tabItem.closedAt} + .timeMsPref=${ifDefined(this.timeMsPref)} + .title=${tabItem.title} + .url=${tabItem.url} + > + </fxview-tab-row> + ` + )} + </div> + <slot name="menu"></slot> + `; + } +} +customElements.define("fxview-tab-list", FxviewTabList); + +/** + * A tab item that displays favicon, title, url, and time of last access + * + * @property {boolean} active - Should current item have focus on keydown + * @property {string} currentActiveElementId - ID of currently focused element within each tab item + * @property {string} dateTimeFormat - Expected format for date and/or time + * @property {string} hasPopup - The aria-haspopup attribute for the secondary action, if required + * @property {number} tabid - The tab ID for when the tab item. + * @property {string} favicon - The favicon for the tab item. + * @property {string} primaryL10nId - The l10n id used for the primary action element + * @property {string} primaryL10nArgs - The l10n args used for the primary action element + * @property {string} secondaryL10nId - The l10n id used for the secondary action button + * @property {string} secondaryL10nArgs - The l10n args used for the secondary action element + * @property {number} time - The timestamp for when the tab was last accessed. + * @property {string} title - The title for the tab item. + * @property {string} url - The url for the tab item. + * @property {number} timeMsPref - The frequency in milliseconds of updates to relative time + */ +export class FxviewTabRow extends MozLitElement { + constructor() { + super(); + this.active = false; + this.currentActiveElementId = "fxview-tab-row-main"; + } + + static properties = { + active: { type: Boolean }, + currentActiveElementId: { type: String }, + dateTimeFormat: { type: String }, + favicon: { type: String }, + hasPopup: { type: String }, + primaryL10nId: { type: String }, + primaryL10nArgs: { type: String }, + secondaryL10nId: { type: String }, + secondaryL10nArgs: { type: String }, + tabid: { type: Number }, + time: { type: Number }, + title: { type: String }, + timeMsPref: { type: Number }, + url: { type: String }, + }; + + static queries = { + mainEl: ".fxview-tab-row-main", + buttonEl: ".fxview-tab-row-button:not([hidden])", + }; + + get currentFocusable() { + return this.renderRoot.getElementById(this.currentActiveElementId); + } + + connectedCallback() { + super.connectedCallback(); + } + + focus() { + this.currentFocusable.focus(); + } + + focusButton() { + this.buttonEl.focus(); + return this.buttonEl.id; + } + + focusLink() { + this.mainEl.focus(); + return this.mainEl.id; + } + + // Use a relative URL in storybook to get faster reloads on style changes. + static stylesheetUrl = window.IS_STORYBOOK + ? "./fxview-tab-row.css" + : "chrome://browser/content/firefoxview/fxview-tab-row.css"; + + dateFluentArgs(timestamp, dateTimeFormat) { + if (dateTimeFormat === "date" || dateTimeFormat === "dateTime") { + return JSON.stringify({ date: timestamp }); + } + return null; + } + + dateFluentId(timestamp, dateTimeFormat, _nowThresholdMs = NOW_THRESHOLD_MS) { + if (!timestamp) { + return ""; + } + if (dateTimeFormat === "relative") { + const elapsed = Date.now() - timestamp; + if (elapsed <= _nowThresholdMs || !lazy.relativeTimeFormat) { + // Use a different string for very recent timestamps + return "fxviewtabrow-just-now-timestamp"; + } + return null; + } else if (dateTimeFormat === "date" || dateTimeFormat === "dateTime") { + return "fxviewtabrow-date"; + } + return null; + } + + relativeTime(timestamp, dateTimeFormat, _nowThresholdMs = NOW_THRESHOLD_MS) { + if (dateTimeFormat === "relative") { + const elapsed = Date.now() - timestamp; + if (elapsed > _nowThresholdMs && lazy.relativeTimeFormat) { + return lazy.relativeTimeFormat.formatBestUnit(new Date(timestamp)); + } + } + return null; + } + + timeFluentId(dateTimeFormat) { + if (dateTimeFormat === "time" || dateTimeFormat === "dateTime") { + return "fxviewtabrow-time"; + } + return null; + } + + formatURIForDisplay(uriString) { + return !window.IS_STORYBOOK + ? lazy.BrowserUtils.formatURIStringForDisplay(uriString) + : uriString; + } + + getImageUrl(icon, targetURI) { + if (!window.IS_STORYBOOK) { + return icon + ? lazy.PlacesUIUtils.getImageURL(icon) + : `page-icon:${targetURI}`; + } + return `chrome://global/skin/icons/defaultFavicon.svg`; + } + + primaryActionHandler(event) { + if ( + (event.type == "click" && !event.altKey) || + (event.type == "keydown" && event.code == "Enter") || + (event.type == "keydown" && event.code == "Space") + ) { + event.preventDefault(); + if (!window.IS_STORYBOOK) { + this.dispatchEvent( + new CustomEvent("fxview-tab-list-primary-action", { + bubbles: true, + composed: true, + detail: { originalEvent: event, item: this }, + }) + ); + } + } + } + + secondaryActionHandler(event) { + if ( + (event.type == "click" && event.detail && !event.altKey) || + // detail=0 is from keyboard + (event.type == "click" && !event.detail) + ) { + event.preventDefault(); + this.dispatchEvent( + new CustomEvent("fxview-tab-list-secondary-action", { + bubbles: true, + composed: true, + detail: { originalEvent: event, item: this }, + }) + ); + } + } + + render() { + const title = this.title; + const relativeString = this.relativeTime( + this.time, + this.dateTimeFormat, + !window.IS_STORYBOOK ? this.timeMsPref : NOW_THRESHOLD_MS + ); + const dateString = this.dateFluentId( + this.time, + this.dateTimeFormat, + !window.IS_STORYBOOK ? this.timeMsPref : NOW_THRESHOLD_MS + ); + const dateArgs = this.dateFluentArgs(this.time, this.dateTimeFormat); + const timeString = this.timeFluentId(this.dateTimeFormat); + const time = this.time; + const timeArgs = JSON.stringify({ time }); + return html` + <link + rel="stylesheet" + href="chrome://global/skin/in-content/common.css" + /> + <link rel="stylesheet" href=${this.constructor.stylesheetUrl} /> + <a + href=${this.url} + class="fxview-tab-row-main" + id="fxview-tab-row-main" + tabindex=${this.active && + this.currentActiveElementId === "fxview-tab-row-main" + ? "0" + : "-1"} + data-l10n-id=${this.primaryL10nId} + data-l10n-args=${ifDefined(this.primaryL10nArgs)} + @click=${this.primaryActionHandler} + @keydown=${this.primaryActionHandler} + > + <span + class="fxview-tab-row-favicon icon" + id="fxview-tab-row-favicon" + style=${styleMap({ + backgroundImage: `url(${this.getImageUrl(this.favicon, this.url)})`, + })} + ></span> + <span class="fxview-tab-row-title" id="fxview-tab-row-title"> + ${title} + </span> + <span class="fxview-tab-row-url" id="fxview-tab-row-url"> + ${this.formatURIForDisplay(this.url)} + </span> + <span class="fxview-tab-row-date" id="fxview-tab-row-date"> + <span + ?hidden=${relativeString || !dateString} + data-l10n-id=${ifDefined(dateString)} + data-l10n-args=${ifDefined(dateArgs)} + ></span> + <span ?hidden=${!relativeString}>${relativeString}</span> + </span> + <span + class="fxview-tab-row-time" + id="fxview-tab-row-time" + ?hidden=${!timeString} + data-timestamp=${this.time} + data-l10n-id=${ifDefined(timeString)} + data-l10n-args=${timeArgs} + > + </span> + </a> + <button + class="fxview-tab-row-button ghost-button icon-button semi-transparent" + id="fxview-tab-row-secondary-button" + part="secondary-button" + data-l10n-id=${this.secondaryL10nId} + data-l10n-args=${ifDefined(this.secondaryL10nArgs)} + aria-haspopup=${ifDefined(this.hasPopup)} + @click=${this.secondaryActionHandler} + tabindex="${this.active && + this.currentActiveElementId === "fxview-tab-row-secondary-button" + ? "0" + : "-1"}" + ></button> + `; + } +} + +customElements.define("fxview-tab-row", FxviewTabRow); diff --git a/browser/components/firefoxview/fxview-tab-row.css b/browser/components/firefoxview/fxview-tab-row.css new file mode 100644 index 0000000000..a8efede865 --- /dev/null +++ b/browser/components/firefoxview/fxview-tab-row.css @@ -0,0 +1,135 @@ +/* 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 { + --fxviewtabrow-element-background-hover: color-mix(in srgb, currentColor 14%, transparent); + --fxviewtabrow-element-background-active: color-mix(in srgb, currentColor 21%, transparent); + display: grid; + grid-template-columns: subgrid; + grid-column: span 6; + align-items: stretch; + border-radius: 4px; +} + +@media (prefers-contrast) { + :host { + --fxviewtabrow-element-background-hover: ButtonText; + --fxviewtabrow-element-background-active: ButtonText; + --fxviewtabrow-text-color-hover: ButtonFace; + } +} + +.fxview-tab-row-main { + display: grid; + grid-template-columns: subgrid; + grid-column: span 5; + gap: 16px; + border-radius: 4px; + align-items: center; + padding: 4px 8px; + user-select: none; + cursor: pointer; + text-decoration: none; +} + +.fxview-tab-row-main, +.fxview-tab-row-main:visited, +.fxview-tab-row-main:hover:active, +.fxview-tab-row-button { + color: inherit; +} + +.fxview-tab-row-main:hover, +.fxview-tab-row-button.ghost-button.icon-button:enabled:hover { + background-color: var(--fxviewtabrow-element-background-hover); + color: var(--fxviewtabrow-text-color-hover); +} + +.fxview-tab-row-main:hover:active, +.fxview-tab-row-button.ghost-button.icon-button:enabled:hover:active { + background-color: var(--fxviewtabrow-element-background-active); +} + +@media (prefers-contrast) { + .fxview-tab-row-main, + .fxview-tab-row-main:hover, + .fxview-tab-row-main:active { + background-color: transparent; + border: 1px solid LinkText; + color: LinkText; + } + + .fxview-tab-row-main:visited .fxview-tab-row-main:visited:hover { + border: 1px solid VisitedText; + color: VisitedText; + } +} + +.fxview-tab-row-favicon { + background-size: cover; + -moz-context-properties: fill; + fill: currentColor; + display: inline-block; + min-height: 16px; + min-width: 16px; + position: relative; +} + +.fxview-tab-row-title { + text-overflow: ellipsis; + white-space: nowrap; +} + +.fxview-tab-row-main:hover .fxview-tab-row-title { + text-decoration-line: underline; +} + +.fxview-tab-row-url { + color: var(--text-color-deemphasized); + word-break: break-word; + text-decoration-line: underline; +} + +.fxview-tab-row-title, +.fxview-tab-row-url { + overflow: hidden; +} + +.fxview-tab-row-date, +.fxview-tab-row-time { + color: var(--text-color-deemphasized); + white-space: nowrap; +} + +.fxview-tab-row-url, +.fxview-tab-row-time { + font-weight: 400; +} + +.fxview-tab-row-button { + margin: 0; + cursor: pointer; +} + +@media (prefers-contrast) { + .fxview-tab-row-button { + border: 1px solid ButtonText; + color: ButtonText; + } + + .fxview-tab-row-button.ghost-button.icon-button:enabled:hover { + border: 1px solid SelectedItem; + color: SelectedItem; + } + + .fxview-tab-row-button.ghost-button.icon-button:enabled:active { + color: SelectedItem; + } + + .fxview-tab-row-button.ghost-button.icon-button:enabled, + .fxview-tab-row-button.ghost-button.icon-button:enabled:hover, + .fxview-tab-row-button.ghost-button.icon-button:enabled:active { + background-color: ButtonFace; + } +} diff --git a/browser/components/firefoxview/helpers.mjs b/browser/components/firefoxview/helpers.mjs new file mode 100644 index 0000000000..e733bd7996 --- /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 = "") { + let favicon = document.createElement("div"); + favicon.style.backgroundImage = `url('${getImageUrl(image, targetURI)}')`; + favicon.classList.add("favicon"); + return favicon; +} + +export function getImageUrl(icon, targetURI) { + return icon ? lazy.PlacesUIUtils.getImageURL(icon) : `page-icon:${targetURI}`; +} + +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/history.mjs b/browser/components/firefoxview/history.mjs new file mode 100644 index 0000000000..60b78a77c0 --- /dev/null +++ b/browser/components/firefoxview/history.mjs @@ -0,0 +1,45 @@ +/* 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/. */ + +import { html } from "chrome://global/content/vendor/lit.all.mjs"; +import { ViewPage } from "./viewpage.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "chrome://browser/content/firefoxview/card-container.mjs"; + +class HistoryInView extends ViewPage { + constructor() { + super(); + } + + connectedCallback() { + super.connectedCallback(); + } + + disconnectedCallback() {} + + render() { + if (!this.selectedTab && !this.overview) { + return null; + } + let numRows = this.overview ? 5 : 10; + const itemTemplates = []; + + for (let i = 1; i <= numRows; i++) { + itemTemplates.push(html` <p>History Row ${i}</p> `); + } + + return html` + <card-container + .viewAllPage=${this.overview ? "history" : null} + ?preserveCollapseState=${this.overview ? true : null} + > + <h2 slot="header" data-l10n-id="firefoxview-history-header"></h2> + <ul slot="main"> + ${itemTemplates} + </ul> + </card-container> + `; + } +} +customElements.define("view-history", HistoryInView); diff --git a/browser/components/firefoxview/jar.mn b/browser/components/firefoxview/jar.mn new file mode 100644 index 0000000000..f96b5db009 --- /dev/null +++ b/browser/components/firefoxview/jar.mn @@ -0,0 +1,33 @@ +# 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/card-container.css + content/browser/firefoxview/card-container.mjs + content/browser/firefoxview/firefoxview.html + content/browser/firefoxview/firefoxview-next.html + content/browser/firefoxview/firefoxview.mjs + content/browser/firefoxview/firefoxview-next.mjs + content/browser/firefoxview/history.mjs + content/browser/firefoxview/opentabs.mjs + content/browser/firefoxview/overview.mjs + content/browser/firefoxview/firefoxview.css + content/browser/firefoxview/firefoxview-next.css + content/browser/firefoxview/fxview-category-button.css + content/browser/firefoxview/fxview-category-navigation.css + content/browser/firefoxview/fxview-category-navigation.mjs + content/browser/firefoxview/helpers.mjs + content/browser/firefoxview/fxview-tab-list.css + content/browser/firefoxview/fxview-tab-list.mjs + content/browser/firefoxview/fxview-tab-row.css + content/browser/firefoxview/tab-pickup-container.mjs + content/browser/firefoxview/tab-pickup-list.mjs + content/browser/firefoxview/recently-closed-tabs.mjs + content/browser/firefoxview/viewpage.mjs + content/browser/firefoxview/recently-closed-empty.svg (content/recently-closed-empty.svg) + content/browser/firefoxview/tab-pickup-empty.svg (content/tab-pickup-empty.svg) + content/browser/callout-tab-pickup.svg (content/callout-tab-pickup.svg) + content/browser/callout-tab-pickup-dark.svg (content/callout-tab-pickup-dark.svg) + content/browser/cfr-lightning.svg (content/cfr-lightning.svg) + content/browser/cfr-lightning-dark.svg (content/cfr-lightning-dark.svg) diff --git a/browser/components/firefoxview/moz.build b/browser/components/firefoxview/moz.build new file mode 100644 index 0000000000..6142522153 --- /dev/null +++ b/browser/components/firefoxview/moz.build @@ -0,0 +1,22 @@ +# -*- 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", +] + +TESTING_JS_MODULES += [ + "tests/browser/FirefoxViewTestUtils.sys.mjs", +] + +BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.ini"] + +MOCHITEST_CHROME_MANIFESTS += ["tests/chrome/chrome.ini"] diff --git a/browser/components/firefoxview/opentabs.mjs b/browser/components/firefoxview/opentabs.mjs new file mode 100644 index 0000000000..ce909dd246 --- /dev/null +++ b/browser/components/firefoxview/opentabs.mjs @@ -0,0 +1,37 @@ +/* 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/. */ + +import { html } from "chrome://global/content/vendor/lit.all.mjs"; +import { ViewPage } from "./viewpage.mjs"; + +class OpenTabsInView extends ViewPage { + constructor() { + super(); + } + + connectedCallback() { + super.connectedCallback(); + } + + disconnectedCallback() {} + + render() { + if (!this.selectedTab && !this.overview) { + return null; + } + let numRows = this.overview ? 5 : 10; + const itemTemplates = []; + + for (let i = 1; i <= numRows; i++) { + itemTemplates.push(html` <p>Open Tab Row ${i}</p> `); + } + + return html` + <ul> + ${itemTemplates} + </ul> + `; + } +} +customElements.define("view-opentabs", OpenTabsInView); diff --git a/browser/components/firefoxview/overview.mjs b/browser/components/firefoxview/overview.mjs new file mode 100644 index 0000000000..1bad6fda6b --- /dev/null +++ b/browser/components/firefoxview/overview.mjs @@ -0,0 +1,35 @@ +/* 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/. */ + +import { css, html } from "chrome://global/content/vendor/lit.all.mjs"; +import { ViewPage } from "./viewpage.mjs"; + +class OverviewInView extends ViewPage { + constructor() { + super(); + this.pageType = "overview"; + } + + connectedCallback() { + super.connectedCallback(); + } + + disconnectedCallback() {} + + static get styles() { + return css` + div { + border: 1px solid black; + border-radius: 10px; + width: 100%; + } + } + `; + } + + render() { + return html` <slot></slot> `; + } +} +customElements.define("view-overview", OverviewInView); diff --git a/browser/components/firefoxview/recently-closed-tabs.mjs b/browser/components/firefoxview/recently-closed-tabs.mjs new file mode 100644 index 0000000000..295fc03d27 --- /dev/null +++ b/browser/components/firefoxview/recently-closed-tabs.mjs @@ -0,0 +1,463 @@ +/* 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, + getImageUrl, + onToggleContainer, + NOW_THRESHOLD_MS, +} from "./helpers.mjs"; + +import { + html, + ifDefined, + styleMap, +} from "chrome://global/content/vendor/lit.all.mjs"; +import { MozLitElement } from "chrome://global/content/lit-utils.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 MozLitElement { + constructor() { + super(); + this.maxTabsLength = 25; + this.recentlyClosedTabs = []; + this.lastFocusedIndex = -1; + + // 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, + timeMsPref => { + clearInterval(this.intervalID); + this.intervalID = setInterval(() => this.requestUpdate(), timeMsPref); + this.requestUpdate(); + } + ); + } + + createRenderRoot() { + return this; + } + + static queries = { + tabsList: "ol", + timeElements: { all: "span.closed-tab-li-time" }, + }; + + get fluentStrings() { + if (!this._fluentStrings) { + this._fluentStrings = new Localization(["browser/firefoxView.ftl"], true); + } + return this._fluentStrings; + } + + connectedCallback() { + super.connectedCallback(); + this.intervalID = setInterval(() => this.requestUpdate(), lazy.timeMsPref); + } + + disconnectedCallback() { + clearInterval(this.intervalID); + } + + 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; + } + + openTabAndUpdate(event) { + if ( + (event.type == "click" && !event.altKey) || + (event.type == "keydown" && event.code == "Enter") || + (event.type == "keydown" && event.code == "Space") + ) { + 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); + + // 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"); + this.dismissTabAndUpdateForElement(item); + } + + dismissTabAndUpdateForElement(item) { + let recentlyClosedList = lazy.SessionStore.getClosedTabDataForWindow( + 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; + } + lazy.SessionStore.forgetClosedTab(getWindow(), 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(), + } + ); + } + + updateRecentlyClosedTabs() { + let recentlyClosedTabsData = lazy.SessionStore.getClosedTabDataForWindow( + getWindow() + ); + this.recentlyClosedTabs = recentlyClosedTabsData.slice( + 0, + this.maxTabsLength + ); + this.requestUpdate(); + } + + render() { + let { recentlyClosedTabs } = this; + let closedTabsContainer = document.getElementById( + "recently-closed-tabs-container" + ); + + if (!recentlyClosedTabs.length) { + // Show empty message if no recently closed tabs + closedTabsContainer.toggleContainerStyleForEmptyMsg(true); + return html` ${this.emptyMessageTemplate()} `; + } + + closedTabsContainer.toggleContainerStyleForEmptyMsg(false); + + return html` + <ol class="closed-tabs-list"> + ${recentlyClosedTabs.map((tab, i) => + this.recentlyClosedTabTemplate(tab, !i) + )} + </ol> + `; + } + + willUpdate() { + if (this.tabsList && this.tabsList.contains(document.activeElement)) { + let activeLi = document.activeElement.closest(".closed-tab-li"); + this.lastFocusedIndex = [...this.tabsList.children].indexOf(activeLi); + } else { + this.lastFocusedIndex = -1; + } + } + + updated() { + let focusRestored = false; + if ( + this.lastFocusedIndex >= 0 && + (!this.tabsList || this.lastFocusedIndex >= this.tabsList.children.length) + ) { + if (this.tabsList) { + let items = [...this.tabsList.children]; + let newFocusIndex = items.length - 1; + let newFocus = items[newFocusIndex]; + if (newFocus) { + focusRestored = true; + newFocus.querySelector(".closed-tab-li-main").focus(); + } + } + if (!focusRestored) { + document.getElementById("recently-closed-tabs-header-section").focus(); + } + } + this.lastFocusedIndex = -1; + } + + emptyMessageTemplate() { + return html` + <div + id="recently-closed-tabs-placeholder" + class="placeholder-content" + role="presentation" + > + <img + id="recently-closed-empty-image" + src="chrome://browser/content/firefoxview/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> + `; + } + + recentlyClosedTabTemplate(tab, primary) { + const targetURI = this.getTabStateValue(tab, "url"); + const convertedTime = convertTimestamp( + tab.closedAt, + this.fluentStrings, + lazy.timeMsPref + ); + return html` + <li + class="closed-tab-li" + data-tabid=${tab.closedId} + data-targeturi=${targetURI} + tabindex=${ifDefined(primary ? null : "-1")} + @contextmenu=${e => (this.contextTriggerNode = e.currentTarget)} + > + <span + class="closed-tab-li-main" + role="button" + tabindex="0" + @click=${e => this.openTabAndUpdate(e)} + @keydown=${e => this.openTabAndUpdate(e)} + > + <div + class="favicon" + style=${styleMap({ + backgroundImage: `url(${getImageUrl(tab.icon, targetURI)})`, + })} + ></div> + <a + href=${targetURI} + class="closed-tab-li-title" + tabindex="-1" + @click=${e => e.preventDefault()} + > + ${tab.title} + </a> + <a + href=${targetURI} + class="closed-tab-li-url" + data-l10n-id="firefoxview-tabs-list-tab-button" + data-l10n-args=${JSON.stringify({ targetURI })} + tabindex="-1" + @click=${e => e.preventDefault()} + > + ${formatURIForDisplay(targetURI)} + </a> + <span class="closed-tab-li-time" data-timestamp=${tab.closedAt}> + ${convertedTime} + </span> + </span> + <button + class="closed-tab-li-dismiss" + data-l10n-id="firefoxview-closed-tabs-dismiss-tab" + data-l10n-args=${JSON.stringify({ tabTitle: tab.title })} + @click=${e => this.dismissTabAndUpdate(e)} + ></button> + </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); + getWindow().addEventListener("command", this, true); + getWindow() + .document.getElementById("contentAreaContextMenu") + .addEventListener("popuphiding", this); + this.open = Services.prefs.getBoolPref(UI_OPEN_STATE, true); + } + + cleanup() { + getWindow().gBrowser.tabContainer.removeEventListener("TabSelect", this); + getWindow().removeEventListener("command", this, true); + getWindow() + .document.getElementById("contentAreaContextMenu") + .removeEventListener("popuphiding", 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.updateRecentlyClosedTabs(); + } 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.updateRecentlyClosedTabs(); + } + } + + onLoad() { + this.list.updateRecentlyClosedTabs(); + this.addObserversIfNeeded(); + } + + handleEvent(event) { + if (event.type == "toggle") { + onToggleContainer(this); + } else if (event.type == "TabSelect") { + this.handleObservers(event.target.linkedBrowser.contentDocument); + } else if ( + event.type === "command" && + event.target.closest(".context-menu-open-link") && + this.list.contextTriggerNode + ) { + this.list.dismissTabAndUpdateForElement(this.list.contextTriggerNode); + } else if (event.type === "popuphiding") { + delete this.list.contextTriggerNode; + } + } + + toggleContainerStyleForEmptyMsg(visible) { + this.collapsibleContainer.classList.toggle("empty-container", visible); + } + + getClosedTabCount = () => { + try { + return lazy.SessionStore.getClosedTabCountForWindow(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..46dc6f32cb --- /dev/null +++ b/browser/components/firefoxview/tab-pickup-container.mjs @@ -0,0 +1,295 @@ +/* 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 { SyncedTabsErrorHandler } = ChromeUtils.importESModule( + "resource:///modules/firefox-view-synced-tabs-error-handler.sys.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; + this._id = Math.floor(Math.random() * 10e6); + } + 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.ownerDocument.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(); + this.onVisibilityChange(); + } + + promiseChildAdded() { + return new Promise(resolve => { + if (typeof this.tabPickupListElem?.getSyncedTabData == "function") { + resolve(); + return; + } + this.addEventListener( + "list-ready", + event => { + resolve(); + }, + { once: true } + ); + }); + } + + cleanup() { + TabsSetupFlowManager.updateViewVisibility(this._id, "unloaded"); + this.ownerDocument?.removeEventListener("visibilitychange", this); + Services.obs.removeObserver(this.boundObserve, TOPIC_SETUPSTATE_CHANGED); + } + + disconnectedCallback() { + this.cleanup(); + } + + handleEvent(event) { + if (event.type == "toggle") { + onToggleContainer(this); + this.onVisibilityChange(); + return; + } + if (event.type == "click" && event.target.dataset.action) { + const { ErrorType } = SyncedTabsErrorHandler; + switch (event.target.dataset.action) { + case `view0-${ErrorType.SYNC_ERROR}-action`: + case `view0-${ErrorType.NETWORK_OFFLINE}-action`: + case `view0-${ErrorType.PASSWORD_LOCKED}-action`: { + TabsSetupFlowManager.tryToClearError(); + break; + } + case `view0-${ErrorType.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-${ErrorType.SYNC_DISCONNECTED}-action`: { + const win = event.target.ownerGlobal; + const { switchToTabHavingURI } = + win.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") { + this.onVisibilityChange(); + } + } + onVisibilityChange() { + const isVisible = document.visibilityState == "visible"; + const isOpen = this.open; + if (isVisible && isOpen) { + this.update(); + TabsSetupFlowManager.updateViewVisibility(this._id, "visible"); + } else { + TabsSetupFlowManager.updateViewVisibility( + this._id, + isVisible ? "closed" : "hidden" + ); + } + } + + 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 = SyncedTabsErrorHandler.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() { + 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 = + SyncedTabsErrorHandler.getFluentStringsForErrorType(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..4a6fef2d7c --- /dev/null +++ b/browser/components/firefoxview/tab-pickup-list.mjs @@ -0,0 +1,417 @@ +/* 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, { + SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs", +}); + +import { + formatURIForDisplay, + convertTimestamp, + getImageUrl, + 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.currentSyncedTabs = []; + 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() { + // when pref is 0, avoid the update altogether (used for tests) + if (!lazy.timeMsPref) { + return; + } + 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); + } + + tabsEqual(a, b) { + return JSON.stringify(a) == JSON.stringify(b); + } + + updateTabsList(syncedTabs) { + if (!syncedTabs.length) { + while (this.tabsList.firstChild) { + this.tabsList.firstChild.remove(); + } + this.togglePlaceholderVisibility(true); + this.tabsList.hidden = true; + this.currentSyncedTabs = syncedTabs; + this.sendTabTelemetry(0); + return; + } + + // Slice syncedTabs to maxTabsLength assuming maxTabsLength + // doesn't change between renders + const tabsToRender = syncedTabs.slice(0, this.maxTabsLength); + + // Pad the render list with placeholders + for (let i = tabsToRender.length; i < this.maxTabsLength; i++) { + tabsToRender.push({ + type: "placeholder", + }); + } + + // Return early if new tabs are the same as previous ones + if ( + JSON.stringify(tabsToRender) == JSON.stringify(this.currentSyncedTabs) + ) { + return; + } + + for (let i = 0; i < tabsToRender.length; i++) { + const tabData = tabsToRender[i]; + let li = this.tabsList.children[i]; + if (li) { + if (this.tabsEqual(tabData, this.currentSyncedTabs[i])) { + // Nothing to change + continue; + } + if (tabData.type == "placeholder") { + // Replace a tab item with a placeholder + this.tabsList.replaceChild(this.generatePlaceholder(), li); + continue; + } else if (this.currentSyncedTabs[i]?.type == "placeholder") { + // Replace the placeholder with a tab item + const tabItem = this.generateListItem(i); + this.tabsList.replaceChild(tabItem, li); + li = tabItem; + } + } else if (tabData.type == "placeholder") { + this.tabsList.appendChild(this.generatePlaceholder()); + continue; + } else { + li = this.tabsList.appendChild(this.generateListItem(i)); + } + this.updateListItem(li, tabData); + } + + this.currentSyncedTabs = tabsToRender; + // Record the full tab count + this.sendTabTelemetry(syncedTabs.length); + + if (this.tabsList.hidden) { + this.tabsList.hidden = false; + this.togglePlaceholderVisibility(false); + + if (!this.intervalID) { + this.intervalID = setInterval(() => this.updateTime(), lazy.timeMsPref); + } + } + } + + 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; + } + + /* + Populate a list item with content from a tab object + */ + updateListItem(li, tab) { + const targetURI = tab.url; + const lastUsedMs = tab.lastUsed * 1000; + const deviceText = tab.device; + + li.dataset.deviceType = tab.deviceType; + + li.querySelector("a").href = targetURI; + li.querySelector(".synced-tab-li-title").textContent = tab.title; + + const favicon = li.querySelector(".favicon"); + const imageUrl = getImageUrl(tab.icon, targetURI); + favicon.style.backgroundImage = `url('${imageUrl}')`; + + const time = li.querySelector(".synced-tab-li-time"); + time.textContent = convertTimestamp(lastUsedMs, this.fluentStrings); + time.setAttribute("data-timestamp", lastUsedMs); + + const deviceIcon = document.createElement("div"); + deviceIcon.classList.add("icon", tab.deviceType); + deviceIcon.setAttribute("role", "presentation"); + + const device = li.querySelector(".synced-tab-li-device"); + device.textContent = deviceText; + device.prepend(deviceIcon); + device.title = deviceText; + + const url = li.querySelector(".synced-tab-li-url"); + url.textContent = formatURIForDisplay(tab.url); + url.title = tab.url; + document.l10n.setAttributes(url, "firefoxview-tabs-list-tab-button", { + targetURI, + }); + } + + /* + Generate an empty list item ready to represent tab data + */ + generateListItem(index) { + // Create new list item + const li = document.createElement("li"); + li.classList.add("synced-tab-li"); + + const a = document.createElement("a"); + a.classList.add("synced-tab-a"); + a.target = "_blank"; + if (index != 0) { + a.setAttribute("tabindex", "-1"); + } + a.addEventListener("keydown", this); + li.appendChild(a); + + const favicon = document.createElement("div"); + favicon.classList.add("favicon"); + a.appendChild(favicon); + + // Hide badge with CSS if not the first child + const badge = this.createBadge(); + a.appendChild(badge); + + const title = document.createElement("span"); + title.classList.add("synced-tab-li-title"); + a.appendChild(title); + + const url = document.createElement("span"); + url.classList.add("synced-tab-li-url"); + a.appendChild(url); + + const device = document.createElement("span"); + device.classList.add("synced-tab-li-device"); + a.appendChild(device); + + const time = document.createElement("span"); + time.classList.add("synced-tab-li-time"); + a.appendChild(time); + + return li; + } + + createBadge() { + const badge = document.createElement("div"); + const dot = document.createElement("span"); + const badgeTextEl = document.createElement("span"); + const badgeText = this.fluentStrings.formatValueSync( + "firefoxview-pickup-tabs-badge" + ); + + badgeTextEl.classList.add("badge-text"); + badgeTextEl.textContent = badgeText; + badge.classList.add("last-active-badge"); + dot.classList.add("dot"); + badge.append(dot, badgeTextEl); + 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/FirefoxViewTestUtils.sys.mjs b/browser/components/firefoxview/tests/browser/FirefoxViewTestUtils.sys.mjs new file mode 100644 index 0000000000..f47bbc2436 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/FirefoxViewTestUtils.sys.mjs @@ -0,0 +1,122 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +import { BrowserTestUtils } from "resource://testing-common/BrowserTestUtils.sys.mjs"; +import { Assert } from "resource://testing-common/Assert.sys.mjs"; +import { TestUtils } from "resource://testing-common/TestUtils.sys.mjs"; + +function assertFirefoxViewTab(win) { + Assert.ok(win.FirefoxViewHandler.tab, "Firefox View tab exists"); + Assert.ok(win.FirefoxViewHandler.tab?.hidden, "Firefox View tab is hidden"); + Assert.equal( + win.gBrowser.visibleTabs.indexOf(win.FirefoxViewHandler.tab), + -1, + "Firefox View tab is not in the list of visible tabs" + ); +} + +async function assertFirefoxViewTabSelected(win) { + assertFirefoxViewTab(win); + Assert.ok( + win.FirefoxViewHandler.tab.selected, + "Firefox View tab is selected" + ); + await BrowserTestUtils.browserLoaded( + win.FirefoxViewHandler.tab.linkedBrowser + ); +} + +async function openFirefoxViewTab(win) { + await BrowserTestUtils.synthesizeMouseAtCenter( + "#firefox-view-button", + { type: "mousedown" }, + win.browsingContext + ); + assertFirefoxViewTab(win); + Assert.ok( + win.FirefoxViewHandler.tab.selected, + "Firefox View tab is selected" + ); + await BrowserTestUtils.browserLoaded( + win.FirefoxViewHandler.tab.linkedBrowser + ); + return win.FirefoxViewHandler.tab; +} + +function closeFirefoxViewTab(win) { + win.gBrowser.removeTab(win.FirefoxViewHandler.tab); + Assert.ok( + !win.FirefoxViewHandler.tab, + "Reference to Firefox View tab got removed when closing the tab" + ); +} + +/** + * Run a task with Firefox View open. + * + * @param {Object} options + * Options object. + * @param {boolean} [options.openNewWindow] + * Whether to run the task in a new window. If false, the current window will + * be used. + * @param {boolean} [options.resetFlowManager] + * Whether to reset the internal state of TabsSetupFlowManager before running + * the task. + * @param {function(MozBrowser)} taskFn + * The task to run. It can be asynchronous. + * @returns {any} + * The value returned by the task. + */ +async function withFirefoxView( + { openNewWindow = false, resetFlowManager = true }, + taskFn +) { + const win = openNewWindow + ? await BrowserTestUtils.openNewBrowserWindow() + : Services.wm.getMostRecentBrowserWindow(); + 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(); + } + // Setting this pref allows the test to run as expected with a keyboard on MacOS + await win.SpecialPowers.pushPrefEnv({ + set: [["accessibility.tabfocus", 7]], + }); + 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" + ); + } + await win.SpecialPowers.popPrefEnv(); + if (openNewWindow) { + await BrowserTestUtils.closeWindow(win); + } + return result; +} + +function isFirefoxViewTabSelectedInWindow(win) { + return win.gBrowser.selectedBrowser.currentURI.spec == "about:firefoxview"; +} + +export { + withFirefoxView, + assertFirefoxViewTab, + assertFirefoxViewTabSelected, + openFirefoxViewTab, + closeFirefoxViewTab, + isFirefoxViewTabSelectedInWindow, +}; diff --git a/browser/components/firefoxview/tests/browser/browser.ini b/browser/components/firefoxview/tests/browser/browser.ini new file mode 100644 index 0000000000..cd00e12504 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser.ini @@ -0,0 +1,37 @@ +[DEFAULT] +support-files = head.js +prefs = + browser.tabs.firefox-view.logLevel=All + +[browser_cfr_message.js] +skip-if = true # Bug 1783684 +[browser_dragDrop_after_opening_fxViewTab.js] +[browser_entrypoint_management.js] +[browser_feature_callout.js] +[browser_feature_callout_position.js] +[browser_feature_callout_resize.js] +[browser_feature_callout_targeting.js] +[browser_feature_callout_theme.js] +[browser_firefoxview.js] +[browser_firefoxview_accessibility.js] +[browser_firefoxview_feature_callout_a11y.js] +[browser_firefoxview_tab.js] +[browser_keyboard_focus.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_close_last_tab.js] +[browser_tab_on_close_warning.js] +[browser_tab_pickup_device_added_telemetry.js] +[browser_tab_pickup_list.js] +skip-if = + os == "linux" # Bug 1824273 + os == "win" # Bug 1824273 +[browser_tab_pickup_visibility.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..337f74c4b1 --- /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.importESModule( + "resource://messaging-system/lib/SpecialMessageActions.sys.mjs" +); + +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_dragDrop_after_opening_fxViewTab.js b/browser/components/firefoxview/tests/browser/browser_dragDrop_after_opening_fxViewTab.js new file mode 100644 index 0000000000..9ce547238a --- /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, + testTab, + [[{ 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..07223b0873 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_feature_callout.js @@ -0,0 +1,771 @@ +/* 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: "about:firefoxview", + }, + message_id: sinon.match("FEATURE_CALLOUT_2"), + }); + spy.assertCalledWith({ + event: "DISMISS", + event_context: { + source: "dismiss_button", + page: "about:firefoxview", + }, + message_id: sinon.match("FEATURE_CALLOUT_2"), + }); + } + ); + sandbox.restore(); +}); + +add_task(async function feature_callout_not_rendered_when_it_has_no_parent() { + Services.telemetry.clearEvents(); + 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; + + const CONTAINER_NOT_CREATED_EVENT = [ + [ + "messaging_experiments", + "feature_callout", + "create_failed", + `${testMessage.message.id}-${testMessage.message.content.screens[0].parent_selector}`, + ], + ]; + await TestUtils.waitForCondition(() => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ).parent; + return events && events.length >= 2; + }, "Waiting for container_not_created event"); + + TelemetryTestUtils.assertEvents( + CONTAINER_NOT_CREATED_EVENT, + { method: "feature_callout" }, + { clear: true, process: "parent" } + ); + + 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_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: "about:firefoxview", + }, + message_id: screenId, + }); + spy.assertCalledWith({ + event: "DISMISS", + event_context: { + source: sinon + .match("PAGE_EVENT:") + .and(sinon.match(testClickSelector)), + page: "about:firefoxview", + }, + message_id: screenId, + }); + + browser.tabDialogBox + ?.getTabDialogManager() + .dialogs.forEach(dialog => dialog.close()); + } + ); + Services.prefs.clearUserPref("browser.firefox-view.view-count"); + 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 feature_callout_dismiss_on_escape() { + await SpecialPowers.pushPrefEnv({ + set: [[featureTourPref, `{"message":"","screen":"","complete":true}`]], + }); + const screenId = "FIREFOX_VIEW_TAB_PICKUP_REMINDER"; + let testMessage = getCalloutMessageById(screenId); + 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("Pressing escape"); + // Press Escape to close + EventUtils.synthesizeKey("KEY_Escape", {}, browser.contentWindow); + await waitForCalloutRemoved(document); + + // Test that appropriate telemetry is sent + spy.assertCalledWith({ + event: "DISMISS", + event_context: { + source: "KEY_Escape", + page: "about:firefoxview", + }, + message_id: screenId, + }); + } + ); + Services.prefs.clearUserPref("browser.firefox-view.view-count"); + sandbox.restore(); + ASRouter.resetMessageState(); +}); + +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(); + } +); + +add_task(async function feature_callout_does_not_display_arrow_if_hidden() { + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS" + ); + testMessage.message.content.screens[0].content.hide_arrow = true; + const sandbox = createSandboxWithCalloutTriggerStub(testMessage); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { document } = browser.contentWindow; + ok( + getComputedStyle( + document.querySelector(".callout-arrow"), + ":before" + ).getPropertyValue("display") == "none" && + getComputedStyle( + document.querySelector(".callout-arrow"), + ":after" + ).getPropertyValue("display") == "none", + "callout arrow is not visible" + ); + } + ); + 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..386fbbb91b --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_feature_callout_position.js @@ -0,0 +1,402 @@ +/* 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..82952b3adf --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_feature_callout_targeting.js @@ -0,0 +1,171 @@ +"use strict"; + +const { BuiltInThemes } = ChromeUtils.importESModule( + "resource:///modules/BuiltInThemes.sys.mjs" +); + +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_feature_callout_theme.js b/browser/components/firefoxview/tests/browser/browser_feature_callout_theme.js new file mode 100644 index 0000000000..f56414145e --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_feature_callout_theme.js @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { FeatureCallout } = ChromeUtils.importESModule( + "resource:///modules/FeatureCallout.sys.mjs" +); + +async function testCallout(config) { + const featureCallout = new FeatureCallout(config); + const testMessage = getCalloutMessageById( + "FIREFOX_VIEW_FEATURE_TOUR_2_NO_CWS" + ); + const screen = testMessage.message.content.screens.find(s => s.id); + screen.parent_selector = "body"; + const sandbox = createSandboxWithCalloutTriggerStub(testMessage, config.page); + featureCallout.showFeatureCallout(); + await waitForCalloutScreen(config.win.document, screen.id); + testStyles(config.win); + return { featureCallout, sandbox }; +} + +function testStyles(win) { + const calloutEl = win.document.querySelector(calloutSelector); + const calloutStyle = win.getComputedStyle(calloutEl); + for (const type of ["light", "dark", "hcm"]) { + for (const name of FeatureCallout.themePropNames) { + ok( + calloutStyle.getPropertyValue(`--fc-${name}-${type}`), + `Theme property --fc-${name}-${type} is set` + ); + } + } +} + +add_task(async function feature_callout_chrome_theme() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + const { sandbox } = await testCallout({ + win, + browser: win.gBrowser.selectedBrowser, + prefName: "fakepref", + page: "chrome", + theme: { preset: "chrome" }, + }); + await BrowserTestUtils.closeWindow(win); + sandbox.restore(); +}); + +add_task(async function feature_callout_pdfjs_theme() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + const { sandbox } = await testCallout({ + win, + browser: win.gBrowser.selectedBrowser, + prefName: "fakepref", + page: "chrome", + theme: { preset: "pdfjs", simulateContent: true }, + }); + await BrowserTestUtils.closeWindow(win); + sandbox.restore(); +}); + +add_task(async function feature_callout_content_theme() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:firefoxview", + }, + async browser => { + const { sandbox } = await testCallout({ + win: browser.contentWindow, + prefName: "fakepref", + page: "about:firefoxview", + theme: { preset: "themed-content" }, + }); + sandbox.restore(); + } + ); +}); 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..7386f109f5 --- /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.value === "primary_button", + `Feature Callout primary button is focused on page load}` + ); + ok(true, "Feature Callout primary button 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.value == "primary_button", + "Feature Callout primary button is focused after advancing screens" + ); + ok(true, "Feature Callout primary button 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..b5a83d6335 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_tab.js @@ -0,0 +1,308 @@ +/* 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"); +} + +function triggerClickOn(target, options) { + let promise = BrowserTestUtils.waitForEvent(target, "click"); + if (AppConstants.platform == "macosx") { + options.metaKey = options.ctrlKey; + delete options.ctrlKey; + } + EventUtils.synthesizeMouseAtCenter(target, options); + return promise; +} + +async function add_new_tab(URL) { + let tab = BrowserTestUtils.addTab(gBrowser, URL); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + return tab; +} + +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({ openNewWindow: true }, 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({ openNewWindow: true }, 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.getClosedTabCountForWindow(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.getClosedTabCountForWindow(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.getClosedTabCountForWindow(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 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"); + }); +}); + +// Test that Firefox View tab is not multiselectable +add_task(async function testFxViewNotMultiselect() { + await withFirefoxView({}, async browser => { + let win = browser.ownerGlobal; + Assert.ok( + win.FirefoxViewHandler.tab.selected, + "Firefox View tab is selected" + ); + let tab2 = await add_new_tab("https://www.mozilla.org"); + let fxViewBtn = win.document.getElementById("firefox-view-button"); + + info("We multi-select a visible tab with ctrl key down"); + await triggerClickOn(tab2, { ctrlKey: true }); + Assert.ok( + tab2.multiselected && gBrowser._multiSelectedTabsSet.has(tab2), + "Second visible tab is (multi) selected" + ); + Assert.equal(gBrowser.multiSelectedTabsCount, 1, "One tab is selected."); + Assert.notEqual( + fxViewBtn, + gBrowser.selectedTab, + "Fx View tab doesn't have focus" + ); + + // Ctrl/Cmd click tab2 again to deselect it + await triggerClickOn(tab2, { ctrlKey: true }); + + info("We multi-select visible tabs with shift key down"); + await triggerClickOn(tab2, { shiftKey: true }); + Assert.ok( + tab2.multiselected && gBrowser._multiSelectedTabsSet.has(tab2), + "Second visible tab is (multi) selected" + ); + Assert.equal(gBrowser.multiSelectedTabsCount, 2, "Two tabs are selected."); + Assert.notEqual( + fxViewBtn, + gBrowser.selectedTab, + "Fx View tab doesn't have focus" + ); + + BrowserTestUtils.removeTab(tab2); + }); +}); 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..254c315fdb --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_keyboard_focus.js @@ -0,0 +1,91 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +ChromeUtils.defineESModuleGetters(globalThis, { + SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs", +}); + +const SYNCED_URI = syncedTabsData1[0].tabs[1].url; + +add_task(async function test_keyboard_focus() { + await SpecialPowers.pushPrefEnv({ + set: [["accessibility.tabfocus", 7]], + }); + + await withFirefoxView({}, 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 = await TestUtils.waitForCondition(() => + 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_notification_dot.js b/browser/components/firefoxview/tests/browser/browser_notification_dot.js new file mode 100644 index 0000000000..01411ee260 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_notification_dot.js @@ -0,0 +1,389 @@ +/* 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.importESModule( + "resource://services-sync/SyncedTabs.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"); +} + +async function clickFirefoxViewButton(win) { + await BrowserTestUtils.synthesizeMouseAtCenter( + "#firefox-view-button", + { type: "mousedown" }, + win.browsingContext + ); +} + +function getBackgroundPositionForElement(ele) { + let style = ele.ownerGlobal.getComputedStyle(ele); + return style.getPropertyValue("background-position"); +} + +let previousFetchTime = 0; + +async function resetSyncedTabsLastFetched() { + Services.prefs.clearUserPref("services.sync.lastTabFetch"); + previousFetchTime = 0; + await TestUtils.waitForTick(); +} + +async function initTabSync() { + let recentFetchTime = Math.floor(Date.now() / 1000); + // ensure we don't try to set the pref with the same value, which will not produce + // the expected pref change effects + while (recentFetchTime == previousFetchTime) { + await TestUtils.waitForTick(); + recentFetchTime = Math.floor(Date.now() / 1000); + } + ok( + recentFetchTime > previousFetchTime, + "The new lastTabFetch value is greater than the previous" + ); + + info("initTabSync, updating lastFetch:" + recentFetchTime); + Services.prefs.setIntPref("services.sync.lastTabFetch", recentFetchTime); + previousFetchTime = recentFetchTime; + await TestUtils.waitForTick(); +} + +add_setup(async function () { + await resetSyncedTabsLastFetched(); + 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" + ); + + info( + "testNotificationDot, button is showing, badge should be initially hidden" + ); + 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 + await clickFirefoxViewButton(win); + + info( + "testNotificationDot, after clicking the button, badge should become hidden" + ); + 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 + info( + "testNotificationDot, after updating the recent tabs, badge should be hidden" + ); + 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 + await clickFirefoxViewButton(win); + + info( + "testNotificationDot, after switching back to fxview, badge should be hidden" + ); + 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(); + + info( + "testNotificationDot, after switching back to fxview with no new tabs, badge should be hidden" + ); + 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(); + + await resetSyncedTabsLastFetched(); + 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"); + + await clickFirefoxViewButton(win2); + + // Make sure the badge doesn't show on any window + info( + "testNotificationDotOnMultipleWindows, badge is initially hidden on window 1" + ); + ok( + await waitForNotificationBadgeToBeHidden(fxViewBtn), + "The notification badge is not showing in the inital window" + ); + info( + "testNotificationDotOnMultipleWindows, badge is initially hidden on window 2" + ); + 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 + info( + "testNotificationDotOnMultipleWindows, after new synced tabs and minimized win2, badge is visible on window 1" + ); + ok( + await waitForNotificationBadgeToBeShowing(fxViewBtn), + "The notification badge is showing in the initial window" + ); + info( + "testNotificationDotOnMultipleWindows, after new synced tabs and minimized win2, badge is visible on window 2" + ); + 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(); + await resetSyncedTabsLastFetched(); + 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 is 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..22889a43eb --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_recently_closed_tabs.js @@ -0,0 +1,886 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(10); + +/** + * The recently closed tab list is populated on a per-window basis. + * + * By default, the withFirefoxView helper opens 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 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_setup(async function setup() { + // set updateTimeMs to 0 to prevent unexpected/unrelated DOM mutations during testing + await SpecialPowers.pushPrefEnv({ + set: [["browser.tabs.firefox-view.updateTimeMs", 100000]], + }); +}); + +add_task(async function test_empty_list() { + clearHistory(); + + await withFirefoxView({}, 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" + ); + + Assert.ok( + document.getElementById("recently-closed-tabs-placeholder"), + "The empty message is displayed." + ); + + Assert.ok( + !document.querySelector("ol.closed-tabs-list"), + "The recently closed tabs list is not displayed." + ); + + 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" + ); + + Assert.ok( + !document.getElementById("recently-closed-tabs-placeholder"), + "The empty message is not displayed." + ); + + Assert.ok( + document.querySelector("ol.closed-tabs-list"), + "The recently closed tabs list is displayed." + ); + + 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.getClosedTabCountForWindow(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({}, 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") + .children[0].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-targeturi"); + let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, uri); + ele.querySelector(".closed-tab-li-main").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.getClosedTabCountForWindow(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({}, 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" + ); + + Assert.ok( + !document.getElementById("recently-closed-tabs-placeholder"), + "The empty message is not displayed." + ); + + Assert.ok( + document.querySelector("ol.closed-tabs-list"), + "The recently closed tabs list is displayed." + ); + + is( + document.querySelector("ol.closed-tabs-list").children.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") + .children[0]; + 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").children.length, + mockMaxTabsLength, + `recently-closed-tabs-list should still have ${mockMaxTabsLength} list items` + ); + }); +}); + +add_task(async function test_time_updates_correctly() { + clearHistory(); + is( + SessionStore.getClosedTabCountForWindow(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, + title: "Example", + }, + ], + }, + ], + }; + await SessionStore.setBrowserState(JSON.stringify(TAB_CLOSED_STATE)); + + is( + SessionStore.getClosedTabCountForWindow(window), + 1, + "Closed tab count after setting browser state" + ); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + const tabsList = document.querySelector("ol.closed-tabs-list"); + const numOfListItems = tabsList.children.length; + const lastListItem = tabsList.children[numOfListItems - 1]; + 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.getClosedTabCountForWindow(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({}, async browser => { + let gBrowser = browser.getTabBrowser(); + const { document } = browser.contentWindow; + 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); + Assert.ok( + document.activeElement.textContent.includes("mochitest index"), + "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({}, 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({}, 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.loadURIString(newTab.linkedBrowser, FINAL_URL); + await loadPromise; + // Close the added tab + BrowserTestUtils.removeTab(newTab); + + const { document } = browser.contentWindow; + await BrowserTestUtils.waitForMutationCondition( + document.querySelector("recently-closed-tabs-list"), + { childList: true, subtree: true }, + () => document.querySelector("ol.closed-tabs-list") + ); + const tabsList = document.querySelector("ol.closed-tabs-list"); + info("A tab appeared in the list, ensure it has the right URL."); + let urlBit = tabsList.children[0].querySelector(".closed-tab-li-url"); + await BrowserTestUtils.waitForMutationCondition( + urlBit, + { characterData: true, attributeFilter: ["title"] }, + () => urlBit.textContent.includes(".com") + ); + Assert.ok( + urlBit.textContent.includes("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.getClosedTabCountForWindow(window), + 0, + "Closed tab count after purging session history" + ); + + await open_then_close(URLs[0]); + + await withFirefoxView({}, async browser => { + let gBrowser = browser.getTabBrowser(); + let originalTabsLength = gBrowser.tabs.length; + await BrowserTestUtils.synthesizeMouseAtCenter( + ".closed-tab-li .closed-tab-li-main", + { 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"); + + // 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]}` + ); + + const contextMenu = gBrowser.ownerDocument.getElementById( + "contentAreaContextMenu" + ); + const promisePopup = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + EventUtils.synthesizeMouseAtCenter( + tabsList.querySelector(".closed-tab-li-title"), + { + button: 2, + type: "contextmenu", + }, + gBrowser.contentWindow + ); + await promisePopup; + const promiseNewTab = BrowserTestUtils.waitForNewTab(gBrowser, URLs[1]); + contextMenu.activateItem( + gBrowser.ownerDocument.getElementById("context-openlinkintab") + ); + await promiseNewTab; + + await BrowserTestUtils.waitForMutationCondition( + tabsList, + { childList: true }, + () => tabsList.children.length === 1 + ); + + Assert.equal( + tabsList.children[0].dataset.targeturi, + URLs[0], + `First recently closed item should be ${URLs[0]}` + ); + + // 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() { + const TAB_UPDATE_TIME_MS = 5; + Services.obs.notifyObservers(null, "browser:purge-session-history"); + Assert.equal( + SessionStore.getClosedTabCountForWindow(window), + 0, + "Closed tab count after purging session history" + ); + await clearAllParentTelemetryEvents(); + + await withFirefoxView({}, 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(); + + await clearAllParentTelemetryEvents(); + + const tabsList = document.querySelector("ol.closed-tabs-list"); + const numOfListItems = tabsList.children.length; + const lastListItem = tabsList.children[numOfListItems - 1]; + 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("Just now") + ); + + isnot( + timeLabel.textContent, + initialTimeText, + "recently-closed-tabs list item time has updated" + ); + + 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" } + ); + + Assert.ok( + document.getElementById("recently-closed-tabs-placeholder"), + "The empty message is displayed." + ); + + Assert.ok( + !document.querySelector("ol.closed-tabs-list"), + "The recently closed tabs list is not displayed." + ); + + await SpecialPowers.popPrefEnv(); + }); +}); + +/** + * Asserts that the actionable part of each list item is role="button". + * Discussion on why we want a button role can be seen here: + * https://bugzilla.mozilla.org/show_bug.cgi?id=1789875#c1 + */ +add_task(async function test_button_role() { + Services.obs.notifyObservers(null, "browser:purge-session-history"); + Assert.equal( + SessionStore.getClosedTabCountForWindow(window), + 0, + "Closed tab count after purging session history" + ); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + + 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 + ); + + const tabsList = document.querySelector("ol.closed-tabs-list"); + + Array.from(tabsList.children).forEach(tabItem => { + let actionableElement = tabItem.querySelector(".closed-tab-li-main"); + Assert.ok(actionableElement.getAttribute("role"), "button"); + }); + }); +}); 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..8d82db2b93 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_recently_closed_tabs_keyboard.js @@ -0,0 +1,269 @@ +/* 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.getClosedTabCountForWindow(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({}, 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(); + + Assert.equal( + list[0].querySelector(".closed-tab-li-main"), + document.activeElement, + "The first link is focused" + ); + + tab(true); + Assert.equal( + summary, + document.activeElement, + "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({}, 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(); + + Assert.equal( + list[0].querySelector(".closed-tab-li-main"), + document.activeElement, + "The first link is focused" + ); + tab(); + tab(); + Assert.equal( + list[1].querySelector(".closed-tab-li-main"), + document.activeElement, + "The second link is focused" + ); + tab(true); + tab(true); + Assert.equal( + list[0].querySelector(".closed-tab-li-main"), + document.activeElement, + "The first link is focused again" + ); + + tab(true); + Assert.equal( + summary, + document.activeElement, + "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({}, 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(); + + Assert.equal( + list[0].querySelector(".closed-tab-li-main"), + document.activeElement, + "The first link is focused" + ); + tab(); + tab(); + Assert.equal( + list[1].querySelector(".closed-tab-li-main"), + document.activeElement, + "The second link is focused" + ); + tab(); + tab(); + Assert.equal( + list[2].querySelector(".closed-tab-li-main"), + document.activeElement, + "The third link is focused" + ); + tab(true); + tab(true); + Assert.equal( + list[1].querySelector(".closed-tab-li-main"), + document.activeElement, + "The second link is focused" + ); + tab(true); + tab(true); + Assert.equal( + list[0].querySelector(".closed-tab-li-main"), + document.activeElement, + "The first link is focused" + ); + }); +}); + +add_task(async function test_dismiss_tab_keyboard() { + Services.obs.notifyObservers(null, "browser:purge-session-history"); + Assert.equal( + SessionStore.getClosedTabCountForWindow(window), + 0, + "Closed tab count after purging session history" + ); + await withFirefoxView({}, 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); + + Assert.ok( + document.getElementById("recently-closed-tabs-placeholder"), + "The empty message is displayed." + ); + + Assert.ok( + !document.querySelector("ol.closed-tabs-list"), + "The recently clsoed tabs list is not displayed." + ); + }); +}); 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..e2733945a0 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_setup_errors.js @@ -0,0 +1,370 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { LoginTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/LoginTestUtils.sys.mjs" +); + +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..7f8722d808 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_setup_primary_password.js @@ -0,0 +1,145 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { LoginTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/LoginTestUtils.sys.mjs" +); + +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").resolves(null); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + syncedTabsMock.resolves(getMockTabData(syncedTabsData1)); + + 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..589f16af26 --- /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 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({ openNewWindow: true }, 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({ openNewWindow: true }, 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({}, 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.pushPrefEnv({ + set: [[MOBILE_PROMO_DISMISSED_PREF, false]], + }); + 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") + ); + + info("Got window, now opening Firefox View in it"); + await withFirefoxView( + { openNewWindow: true, resetFlowManager: false }, + 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, + win2Browser.ownerGlobal + ); + BrowserTestUtils.is_hidden(confirmBox); + + for (let fxviewBrowser of [browser, win2Browser]) { + checkMobilePromo(fxviewBrowser, { + mobilePromo: false, + mobileConfirmation: false, + }); + } + } + ); + }); + 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.loadURIString(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 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..e302b3dee9 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_setup_synced_tabs_loading.js @@ -0,0 +1,176 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +ChromeUtils.defineESModuleGetters(globalThis, { + SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs", +}); + +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 = structuredClone(syncedTabsData1[0].tabs); + 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..d2dc76974c --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_sync_admin_disabled.js @@ -0,0 +1,68 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +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..021fd01bc2 --- /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.loadURIString(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..9980980c29 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_tab_on_close_warning.js @@ -0,0 +1,60 @@ +/* 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_device_added_telemetry.js b/browser/components/firefoxview/tests/browser/browser_tab_pickup_device_added_telemetry.js new file mode 100644 index 0000000000..ce77090077 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_tab_pickup_device_added_telemetry.js @@ -0,0 +1,278 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +registerCleanupFunction(async function () { + await clearAllParentTelemetryEvents(); + cleanup_tab_pickup(); +}); + +function setupWithFxaDevices() { + const sandbox = (gSandbox = setupSyncFxAMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "My desktop", + isCurrentDevice: true, + type: "desktop", + }, + { + id: 2, + name: "Other device", + isCurrentDevice: false, + type: "mobile", + }, + ], + })); + return sandbox; +} + +const mockDesktopTab1 = { + client: "6c12bonqXZh8", + device: "My desktop", + deviceType: "desktop", + 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 mockDesktopTab2 = { + client: "6c12bonqXZh8", + device: "My desktop", + deviceType: "desktop", + 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 mockMobileTab1 = { + client: "9d0y686hBXel", + device: "My phone", + deviceType: "mobile", + type: "tab", + title: "Element", + url: "https://chat.mozilla.org/#room:mozilla.org", + icon: "https://chat.mozilla.org/vector-icons/favicon.ico", + lastUsed: 1664571288, +}; + +const NO_TABS_EVENTS = [ + ["firefoxview", "entered", "firefoxview", undefined], + ["firefoxview", "synced_tabs", "tabs", undefined, { count: "0" }], +]; +const SINGLE_TAB_EVENTS = [ + ["firefoxview", "entered", "firefoxview", undefined], + ["firefoxview", "synced_tabs", "tabs", undefined, { count: "1" }], +]; +const DEVICE_ADDED_NO_TABS_EVENTS = [ + ["firefoxview", "synced_tabs", "tabs", undefined, undefined], + ["firefoxview", "synced_tabs_empty", "since_device_added", undefined], +]; +const DEVICE_ADDED_TABS_EVENTS = [ + ["firefoxview", "synced_tabs", "tabs", undefined, undefined], +]; + +async function whenResolved(functionSpy, functionLabel) { + info(`Waiting for ${functionLabel} to be called`); + await TestUtils.waitForCondition( + () => functionSpy.called, + `Waiting for ${functionLabel} to be called` + ); + is( + functionSpy.getCall(0).returnValue.constructor.name, + "Promise", + `${functionLabel} returned a promise` + ); + info(`Waiting for the promise returned by ${functionLabel} to be resolved`); + await functionSpy.getCall(0).returnValue; + info(`${functionLabel} promise resolved`); +} + +async function test_device_added({ + initialRecentTabsResult, + expectedInitialTelementryEvents, + expectedDeviceAddedTelementryEvents, +}) { + const recentTabsResult = initialRecentTabsResult; + await clearAllParentTelemetryEvents(); + const sandbox = setupWithFxaDevices(); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + + syncedTabsMock.callsFake(() => { + info( + `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${recentTabsResult.length} tabs\n` + ); + return Promise.resolve(recentTabsResult); + }); + + ok( + !isFirefoxViewTabSelected(), + "Before we call withFirefoxView, about:firefoxview tab is not selected" + ); + ok( + !TabsSetupFlowManager.hasVisibleViews, + "Initially hasVisibleViews is false" + ); + + await withFirefoxView({}, async browser => { + info("inside withFirefoxView taskFn, waiting for setupListState"); + const { document } = browser.contentWindow; + const stopWaitingSpy = sandbox.spy( + TabsSetupFlowManager, + "stopWaitingForTabs" + ); + const signedInChangeSpy = sandbox.spy( + TabsSetupFlowManager, + "onSignedInChange" + ); + + await setupListState(browser); + info("setupListState finished"); + + // ensure any tab syncs triggered by Fxa sign-in are complete before proceeding + await whenResolved(signedInChangeSpy, "onSignedInChange"); + if (!recentTabsResult.length) { + info("No synced tabs so we wait for the result of the sync we trigger"); + await whenResolved(stopWaitingSpy, "stopWaitingForTabs"); + info("stopWaitingForTabs finished"); + } + + const isTablistVisible = !!initialRecentTabsResult.length; + testVisibility(browser, { + expectedVisible: { + "ol.synced-tabs-list": isTablistVisible, + "#synced-tabs-placeholder": !isTablistVisible, + }, + }); + const syncedTabsItems = document.querySelectorAll( + "ol.synced-tabs-list > li:not(.synced-tab-li-placeholder)" + ); + info( + "list items: " + + Array.from(syncedTabsItems) + .map(li => `li.${li.className}`) + .join(", ") + ); + is( + syncedTabsItems.length, + initialRecentTabsResult.length, + `synced-tabs-list should have initial count of ${initialRecentTabsResult.length} non-placeholder list items` + ); + + // confirm telemetry is in expected state? + info( + "Checking telemetry against expectedInitialTelementryEvents: " + + JSON.stringify(expectedInitialTelementryEvents, null, 2) + ); + TelemetryTestUtils.assertEvents( + expectedInitialTelementryEvents, + { category: "firefoxview" }, + { clear: true, process: "parent" } + ); + + // add a new mock device + info("Adding a new mock fxa dedvice"); + gMockFxaDevices.push({ + id: 1, + name: "My primary phone", + isCurrentDevice: false, + type: "mobile", + }); + + const startWaitingSpy = sandbox.spy( + TabsSetupFlowManager, + "startWaitingForNewDeviceTabs" + ); + // Notify of the newly added device + info("Notifying devicelist_updated with the new mobile device"); + Services.obs.notifyObservers(null, "fxaccounts:devicelist_updated"); + + // Some time passes here waiting for sync to get data from that device + // we expect new-device handling to kick in. If there are 0 tabs we'll signal we're waiting, + // create a timestamp and only clear it when there are > 0 tabs. + // If there are already > 0 tabs, we'll basically do nothing, showing any new tabs when they arrive + await whenResolved(startWaitingSpy, "startWaitingForNewDeviceTabs"); + + info( + "Initial tabs count: " + + recentTabsResult.length + + ", assert on _noTabsVisibleFromAddedDeviceTimestamp: " + + TabsSetupFlowManager._noTabsVisibleFromAddedDeviceTimestamp + ); + if (recentTabsResult.length) { + ok( + !TabsSetupFlowManager._noTabsVisibleFromAddedDeviceTimestamp, + "Should not be waiting if there were > 0 tabs initially" + ); + } else { + ok( + TabsSetupFlowManager._noTabsVisibleFromAddedDeviceTimestamp, + "Should be waiting if there were 0 tabs initially" + ); + } + + // Add tab data from this new device and notify of the changed data + recentTabsResult.push(mockMobileTab1); + stopWaitingSpy.resetHistory(); + + info("Notifying tabs.changed with the new mobile device's tabs"); + Services.obs.notifyObservers(null, "services.sync.tabs.changed"); + + // handling the tab.change and clearing the timestamp is necessarily async + // as counting synced tabs via getRecentTabs() is async. + // There may not be any outcome depending on the tab state, so we just wait + // for stopWaitingForTabs to get called and its promise to resolve + info("Waiting for the stopWaitingSpy to be called"); + await whenResolved(stopWaitingSpy, "stopWaitingForTabs"); + await TestUtils.waitForTick(); // allow time for the telemetry event to get recorded + + info( + "We've added a synced tab and updated the tab list, got snapshotEvents:" + + JSON.stringify( + Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ), + null, + 2 + ) + ); + // confirm no telemetry was recorded for tabs from the newly-added device + // as the tab list was never empty + info( + "Checking telemetry against expectedDeviceAddedTelementryEvents: " + + JSON.stringify(expectedDeviceAddedTelementryEvents, null, 2) + ); + TelemetryTestUtils.assertEvents( + expectedDeviceAddedTelementryEvents, + { category: "firefoxview" }, + { clear: true, process: "parent" } + ); + }); + sandbox.restore(); + cleanup_tab_pickup(); +} + +add_task(async function test_device_added_with_existing_tabs() { + /* Confirm that no telemetry is recorded when a new device is added while the synced tabs list has tabs */ + await test_device_added({ + initialRecentTabsResult: [mockDesktopTab1], + expectedInitialTelementryEvents: SINGLE_TAB_EVENTS, + expectedDeviceAddedTelementryEvents: DEVICE_ADDED_TABS_EVENTS, + }); +}); + +add_task(async function test_device_added_with_empty_list() { + /* Confirm that telemetry is recorded when a device is added and the synced tabs list + is empty until its tabs get synced + */ + await test_device_added({ + initialRecentTabsResult: [], + expectedInitialTelementryEvents: NO_TABS_EVENTS, + expectedDeviceAddedTelementryEvents: DEVICE_ADDED_NO_TABS_EVENTS, + }); +}); 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..875b8a5a10 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_tab_pickup_list.js @@ -0,0 +1,794 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +ChromeUtils.defineESModuleGetters(globalThis, { + SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs", +}); + +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 desktopTabs = [ + { + 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 Jan 19 1970 22:55:30 GMT+0000 + }, + { + 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: 1673991540155, // Tue, 17 Jan 2023 16:39:00 GMT + }, + { + type: "tab", + title: "Bugzilla Main Page", + url: "https://bugzilla.mozilla.org/", + icon: "https://bugzilla.mozilla.org/extensions/BMO/web/images/favicon.ico", + lastUsed: 1673513538000, // Thu, 12 Jan 2023 03:52:18 GMT + }, +]; + +const mobileTabs = [ + { + 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: 1606510800000, // Fri Nov 27 2020 16:00:00 GMT+0000 + }, + { + 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: 1606510800000, // Fri Nov 27 2020 16:00:00 GMT+0000 + }, +]; + +const syncedTabsData6 = structuredClone(syncedTabsData1); +syncedTabsData6[0].tabs = desktopTabs; +syncedTabsData6[1].tabs = mobileTabs; + +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_setup(async function setup() { + // set updateTimeMs to 0 to prevent unexpected/unrelated DOM mutations during testing + await SpecialPowers.pushPrefEnv({ + set: [["browser.tabs.firefox-view.updateTimeMs", 0]], + }); +}); + +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); + let getRecentTabsResult = mockTabs1; + syncedTabsMock.callsFake(() => { + info( + `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n` + ); + return Promise.resolve(getRecentTabsResult); + }); + + 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" + ); + + getRecentTabsResult = 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, subtree: 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); + let getRecentTabsResult = mockTabs1; + syncedTabsMock.callsFake(() => { + info( + `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n` + ); + return Promise.resolve(getRecentTabsResult); + }); + + 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" + ); + + getRecentTabsResult = 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, subtree: 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); + let getRecentTabsResult = mockTabs1; + syncedTabsMock.callsFake(() => { + info( + `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n` + ); + return Promise.resolve(getRecentTabsResult); + }); + + 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" } + ); + + getRecentTabsResult = 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, subtree: 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); + let getRecentTabsResult = mockTabs1; + syncedTabsMock.callsFake(() => { + info( + `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n` + ); + return Promise.resolve(getRecentTabsResult); + }); + + 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, subtree: 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); + let getRecentTabsResult = mockTabs1; + syncedTabsMock.callsFake(() => { + info( + `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n` + ); + return Promise.resolve(getRecentTabsResult); + }); + + 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"); + + let waitedForTabs = TestUtils.waitForCondition(() => { + return !TabsSetupFlowManager.waitingForTabs; + }); + + getRecentTabsResult = expectedTabsAfterReload; + Services.obs.notifyObservers(null, "services.sync.tabs.changed"); + + const syncedTabsList = document.querySelector("ol.synced-tabs-list"); + // The tab pickup list has been updated + await BrowserTestUtils.waitForMutationCondition( + syncedTabsList, + { childList: true, subtree: true }, + () => + syncedTabsList.firstChild.textContent.includes("Sandboxes - Sinon.JS") + ); + await waitedForTabs; + + sandbox.restore(); + cleanup_tab_pickup(); + }); +}); + +add_task(async function test_keyboard_navigation() { + TabsSetupFlowManager.resetInternalState(); + + const sandbox = setupRecentDeviceListMocks(); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + let mockTabs1 = getMockTabData(syncedTabsData1); + let getRecentTabsResult = mockTabs1; + syncedTabsMock.callsFake(() => { + info( + `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n` + ); + return Promise.resolve(getRecentTabsResult); + }); + + 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(); + }); +}); + +add_task(async function test_duplicate_tab_filter() { + const sandbox = setupRecentDeviceListMocks(); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + let mockTabs6 = getMockTabData(syncedTabsData6); + let getRecentTabsResult = mockTabs6; + syncedTabsMock.callsFake(() => { + info( + `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n` + ); + return Promise.resolve(getRecentTabsResult); + }); + + await withFirefoxView({}, async browser => { + await setupListState(browser); + + Assert.equal( + mockTabs6[0].title, + "Firefox Privacy Notice", + `First tab should be ${mockTabs6[0].title}` + ); + + Assert.equal( + mockTabs6[0].lastUsed, + 1673991540155, + `First tab lastUsed value should be ${mockTabs6[0].lastUsed}` + ); + + Assert.equal( + mockTabs6[1].title, + "Bugzilla Main Page", + `Second tab should be ${mockTabs6[1].title}` + ); + + Assert.equal( + mockTabs6[1].lastUsed, + 1673513538000, + `Second tab lastUsed value should be ${mockTabs6[1].lastUsed}` + ); + + Assert.equal( + mockTabs6[2].title, + "Internet for people, not profits - Mozilla", + `Third tab should be ${mockTabs6[2].title}` + ); + + Assert.equal( + mockTabs6[2].lastUsed, + 1606510800000, + `Third tab lastUsed value should be ${mockTabs6[2].lastUsed}` + ); + + sandbox.restore(); + cleanup_tab_pickup(); + }); +}); + +add_task(async function test_tabs_dont_update_unnecessarily() { + const sandbox = setupRecentDeviceListMocks(); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + let mockTabs1 = getMockTabData(syncedTabsData1); + let getRecentTabsResult = mockTabs1; + syncedTabsMock.callsFake(() => { + info( + `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n` + ); + return Promise.resolve(getRecentTabsResult); + }); + + await withFirefoxView({}, async browser => { + await setupListState(browser); + + const { document } = browser.contentWindow; + const syncedTabsList = document.querySelector("ol.synced-tabs-list"); + + Assert.ok( + syncedTabsList.children.length === 3, + "Tab Pickup list should have three list items" + ); + + Assert.ok( + syncedTabsList.firstChild.textContent.includes( + "Internet for people, not profits - Mozilla" + ), + `First item in the Tab Pickup list is ${mockTabs1[0].title}` + ); + + Assert.ok( + syncedTabsList.children[1].textContent.includes("The Times"), + `Second item in Tab Pickup list is ${mockTabs1[1].title}` + ); + + Assert.ok( + syncedTabsList.children[2].textContent.includes("Sandboxes - Sinon.JS"), + `Third item in Tab Pickup list is ${mockTabs1[2].title}` + ); + + let wasMutated = false; + + const callback = mutationList => { + // some logging so if this starts to fail we have some clues as to why + for (const mutation of mutationList) { + if (mutation.type === "childList") { + info( + "A child node has been added or removed:" + mutation.target.nodeName + ); + } else if (mutation.type === "attributes") { + info(`The ${mutation.attributeName} attribute was modified.`); + } else if (mutation.type === "characterData") { + info(`The characterData was modified.`); + } + } + wasMutated = true; + }; + + const observer = new MutationObserver(callback); + + observer.observe(syncedTabsList, { childList: true, subtree: true }); + + getRecentTabsResult = mockTabs1; + const tabPickupList = document.querySelector("tab-pickup-list"); + const updateTabsListSpy = sandbox.spy(tabPickupList, "updateTabsList"); + + // Initiate a synced tabs update + Services.obs.notifyObservers(null, "services.sync.tabs.changed"); + await TestUtils.waitForCondition(() => { + return !TabsSetupFlowManager.waitingForTabs; + }); + await TestUtils.waitForCondition(() => updateTabsListSpy.called); + Assert.ok(!wasMutated, "The synced tabs list was not mutated"); + + observer.disconnect(); + sandbox.restore(); + cleanup_tab_pickup(); + }); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_tab_pickup_visibility.js b/browser/components/firefoxview/tests/browser/browser_tab_pickup_visibility.js new file mode 100644 index 0000000000..d9ec59a57f --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_tab_pickup_visibility.js @@ -0,0 +1,149 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +registerCleanupFunction(async function () { + Services.prefs.clearUserPref(TAB_PICKUP_STATE_PREF); +}); + +async function setup({ open } = {}) { + TabsSetupFlowManager.resetInternalState(); + // sanity check initial values + ok( + !TabsSetupFlowManager.hasVisibleViews, + "Initially hasVisibleViews is false" + ); + is( + TabsSetupFlowManager._viewVisibilityStates.size, + 0, + "Initially, there are no visible views" + ); + ok( + !isFirefoxViewTabSelected(), + "During setup, the about:firefoxview tab is not selected" + ); + + if (typeof open == "undefined") { + Services.prefs.clearUserPref(TAB_PICKUP_STATE_PREF); + } else { + await SpecialPowers.pushPrefEnv({ + set: [[TAB_PICKUP_STATE_PREF, open]], + }); + } + const sandbox = sinon.createSandbox(); + sandbox.stub(TabsSetupFlowManager, "isTabSyncSetupComplete").get(() => true); + return sandbox; +} + +add_task(async function test_tab_pickup_visibility() { + /* Confirm the correct number of tab-pickup views are registered as visible */ + const sandbox = await setup(); + + await withFirefoxView({ win: window }, async function (browser) { + const { document } = browser.contentWindow; + let tabPickupContainer = document.querySelector("#tab-pickup-container"); + + ok(tabPickupContainer.open, "Tab Pickup container should be open"); + ok(isFirefoxViewTabSelected(), "The firefox view tab is selected"); + ok(TabsSetupFlowManager.hasVisibleViews, "hasVisibleViews"); + is(TabsSetupFlowManager._viewVisibilityStates.size, 1, "One view"); + + info("Opening and switching to different tab to background fx-view"); + let newTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:mozilla" + ); + ok(!isFirefoxViewTabSelected(), "The firefox view tab is not selected"); + ok( + !TabsSetupFlowManager.hasVisibleViews, + "no view visible when fx-view is not active" + ); + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + await openFirefoxViewTab(newWin); + + ok( + isFirefoxViewTabSelected(newWin), + "The firefox view tab in the new window is selected" + ); + ok( + TabsSetupFlowManager.hasVisibleViews, + "view registered as visible when fx-view is opened in a new window" + ); + is(TabsSetupFlowManager._viewVisibilityStates.size, 2, "2 tracked views"); + + await BrowserTestUtils.closeWindow(newWin); + + ok( + !isFirefoxViewTabSelected(), + "The firefox view tab in the original window is not selected" + ); + ok( + !TabsSetupFlowManager.hasVisibleViews, + "no visible views when fx-view is not the active tab in the remaining window" + ); + is( + TabsSetupFlowManager._viewVisibilityStates.size, + 1, + "Back to one tracked view" + ); + + // Switch back to FxView: + await BrowserTestUtils.switchTab( + gBrowser, + gBrowser.getTabForBrowser(browser) + ); + + ok( + isFirefoxViewTabSelected(), + "The firefox view tab in the original window is now selected" + ); + ok( + TabsSetupFlowManager.hasVisibleViews, + "View visibility updated when we switch tab" + ); + BrowserTestUtils.removeTab(newTab); + }); + sandbox.restore(); + await SpecialPowers.popPrefEnv(); + ok( + !TabsSetupFlowManager.hasVisibleViews, + "View visibility updated after withFirefoxView" + ); +}); + +add_task(async function test_instance_closed() { + /* Confirm tab-pickup views are correctly accounted for when toggled closed */ + const sandbox = await setup({ open: false }); + await withFirefoxView({ win: window }, async function (browser) { + const { document } = browser.contentWindow; + info( + "tab-pickup.open pref: " + + Services.prefs.getBoolPref( + "browser.tabs.firefox-view.ui-state.tab-pickup.open" + ) + ); + info( + "isTabSyncSetupComplete: " + TabsSetupFlowManager.isTabSyncSetupComplete + ); + let tabPickupContainer = document.querySelector("#tab-pickup-container"); + ok(!tabPickupContainer.open, "Tab Pickup container should be closed"); + info( + "_viewVisibilityStates" + + JSON.stringify( + Array.from(TabsSetupFlowManager._viewVisibilityStates.values()), + null, + 2 + ) + ); + ok(!TabsSetupFlowManager.hasVisibleViews, "no visible views"); + is( + TabsSetupFlowManager._viewVisibilityStates.size, + 1, + "One registered view" + ); + + tabPickupContainer.open = true; + await TestUtils.waitForTick(); + ok(TabsSetupFlowManager.hasVisibleViews, "view visible"); + }); + sandbox.restore(); +}); 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..979a8c9d5c --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_ui_state.js @@ -0,0 +1,141 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +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..40a8c0cac2 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/head.js @@ -0,0 +1,550 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { + withFirefoxView, + assertFirefoxViewTab, + assertFirefoxViewTabSelected, + openFirefoxViewTab, + closeFirefoxViewTab, + isFirefoxViewTabSelectedInWindow, +} = ChromeUtils.importESModule( + "resource://testing-common/FirefoxViewTestUtils.sys.mjs" +); + +/* exported testVisibility */ + +const { ASRouter } = ChromeUtils.import( + "resource://activity-stream/lib/ASRouter.jsm" +); +const { UIState } = ChromeUtils.importESModule( + "resource://services-sync/UIState.sys.mjs" +); +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +const { FeatureCalloutMessages } = ChromeUtils.importESModule( + "resource://activity-stream/lib/FeatureCalloutMessages.sys.mjs" +); +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + SyncedTabs: "resource://services-sync/SyncedTabs.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 = "multi-stage-message-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` + ); + } + } +} + +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) { + return SyncedTabs._internal._createRecentTabsList(clients, 3); +} + +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.sys.mjs 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} testMessage + * @param {string} [source="about:firefoxview"] + */ +const createSandboxWithCalloutTriggerStub = ( + testMessage, + source = "about:firefoxview" +) => { + const firefoxViewMatch = sinon.match({ + id: "featureCalloutCheck", + context: { source }, + }); + 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); +} + +function isFirefoxViewTabSelected(win = window) { + return isFirefoxViewTabSelectedInWindow(win); +} + +registerCleanupFunction(() => { + is( + typeof SyncedTabs._internal?._createRecentTabsList, + "function", + "in firefoxview/head.js, SyncedTabs._internal._createRecentTabsList is a function" + ); + // ensure all the stubs are restored, regardless of any exceptions + // that might have prevented it + gSandbox?.restore(); +}); diff --git a/browser/components/firefoxview/tests/chrome/chrome.ini b/browser/components/firefoxview/tests/chrome/chrome.ini new file mode 100644 index 0000000000..a6a1475190 --- /dev/null +++ b/browser/components/firefoxview/tests/chrome/chrome.ini @@ -0,0 +1,5 @@ +[DEFAULT] + +[test_card_container.html] +[test_fxview_category_navigation.html] +[test_fxview_tab_list.html] diff --git a/browser/components/firefoxview/tests/chrome/test_card_container.html b/browser/components/firefoxview/tests/chrome/test_card_container.html new file mode 100644 index 0000000000..c76c6ec222 --- /dev/null +++ b/browser/components/firefoxview/tests/chrome/test_card_container.html @@ -0,0 +1,122 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>CardContainer Tests</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css"> + <link rel="localization" href="browser/firefoxView.ftl"/> + <script type="module" src="chrome://browser/content/firefoxview/card-container.mjs"></script> +</head> +<body> + <style> + </style> +<p id="display"></p> +<div id="content"> + <card-container viewAllPage="history"> + <h2 slot="header" data-l10n-id="history-header"></h2> + <ul slot="main"> + <li>History Row 1</li> + <li>History Row 2</li> + <li>History Row 3</li> + <li>History Row 4</li> + <li>History Row 5</li> + </ul> + </card-container> +</div> +<pre id="test"> +<script class="testbody" type="application/javascript"> + const cardContainer = document.querySelector("card-container"); + + /** + * Tests that the card-container can expand and collapse when the summary element is clicked + */ + add_task(async function test_open_close_card() { + is( + cardContainer.isExpanded, + true, + "The card-container is expanded initially" + ); + + // Click the summary to collapse the details disclosure + cardContainer.summaryEl.click(); + is( + cardContainer.detailsEl.hasAttribute("open"), + false, + "The card-container is collapsed" + ); + + // Click on the summary again to expand the details disclosure + cardContainer.summaryEl.click(); + is( + cardContainer.detailsEl.hasAttribute("open"), + true, + "The card-container is expanded" + ); + }); + + /** + * Tests keyboard navigation of the card-container component + */ + add_task(async function test_keyboard_navigation() { + const tab = async shiftKey => { + info(`Tab${shiftKey ? ' + Shift' : ''}`); + synthesizeKey("KEY_Tab", { shiftKey }); + }; + const enter = async () => { + info("Enter"); + synthesizeKey("KEY_Enter", {}); + }; + + // Setting this pref allows the test to run as expected with a keyboard on MacOS + await SpecialPowers.pushPrefEnv({ + set: [["accessibility.tabfocus", 7]], + }); + + cardContainer.summaryEl.focus(); + is( + cardContainer.shadowRoot.activeElement, + cardContainer.summaryEl, + "Focus should be on the summary element within card-container" + ); + + // Tab to the 'View all' link + await tab(); + is( + cardContainer.shadowRoot.activeElement, + cardContainer.viewAllLink, + "Focus should be on the 'View all' link within card-container" + ); + + // Shift + Tab back to the summary element + await tab(true); + is( + cardContainer.shadowRoot.activeElement, + cardContainer.summaryEl, + "Focus should be back on the summary element within card-container" + ); + + // Select the summary to collapse the details disclosure + await enter(); + is( + cardContainer.detailsEl.hasAttribute("open"), + false, + "The card-container is collapsed" + ); + + // Select the summary again to expand the details disclosure + await enter(); + is( + cardContainer.detailsEl.hasAttribute("open"), + true, + "The card-container is expanded" + ); + + await SpecialPowers.popPrefEnv(); + }); +</script> +</pre> +</body> +</html> diff --git a/browser/components/firefoxview/tests/chrome/test_fxview_category_navigation.html b/browser/components/firefoxview/tests/chrome/test_fxview_category_navigation.html new file mode 100644 index 0000000000..d074d96740 --- /dev/null +++ b/browser/components/firefoxview/tests/chrome/test_fxview_category_navigation.html @@ -0,0 +1,322 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>FxviewCategoryNavigation Tests</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css"> + <script type="module" src="chrome://browser/content/firefoxview/fxview-category-navigation.mjs"></script> +</head> +<style> +body { + display: flex; +} +#navigation { + width: var(--in-content-sidebar-width); +} +fxview-category-button[name="category-one"]::part(icon) { + background-image: url("chrome://mozapps/skin/extensions/category-discover.svg"); +} +fxview-category-button[name="category-two"]::part(icon) { + background-image: url("chrome://mozapps/skin/extensions/category-discover.svg"); +} +fxview-category-button[name="category-three"]::part(icon) { + background-image: url("chrome://mozapps/skin/extensions/category-discover.svg"); +} +fxview-category-button[name="category-four"]::part(icon) { + background-image: url("chrome://mozapps/skin/extensions/category-discover.svg"); +} +fxview-category-button[name="category-five"]::part(icon) { + background-image: url("chrome://mozapps/skin/extensions/category-discover.svg"); +} +</style> +<body> + <p id="display"></p> + <div id="content"> + <div id="navigation"> + <fxview-category-navigation> + <h2 slot="category-nav-header">Header</h2> + <fxview-category-button class="category" slot="category-button" name="category-one"> + <span class="category-name">Category 1</span> + </fxview-category-button> + <fxview-category-button class="category" slot="category-button" name="category-two"> + <span class="category-name">Category 2</span> + </fxview-category-button> + <fxview-category-button class="category" slot="category-button" name="category-three"> + <span class="category-name">Category 3</span> + </fxview-category-button> + <fxview-category-button class="category" slot="category-button" name="category-four"> + <span class="category-name">Category 4</span> + </fxview-category-button> + <fxview-category-button class="category" slot="category-button" name="category-five"> + <span class="category-name">Category 5</span> + </fxview-category-button> + </fxview-category-navigation> + </div> + </div> +<pre id="test"></pre> +<script> + Services.scriptloader.loadSubScript( + "chrome://browser/content/utilityOverlay.js", + this + ); + const { BrowserTestUtils } = ChromeUtils.import( + "resource://testing-common/BrowserTestUtils.jsm" + ); + +const fxviewCategoryNav = document.querySelector("fxview-category-navigation"); + +function isActiveElement(expectedActiveEl) { + return expectedActiveEl.getRootNode().activeElement == expectedActiveEl; + } + + /** + * Tests that the first category is selected by default + */ + add_task(async function test_first_item_selected_by_default() { + is( + fxviewCategoryNav.categoryButtons.length, + 5, + "Five category buttons are in the navigation" + ); + + ok( + fxviewCategoryNav.categoryButtons[0].name === fxviewCategoryNav.currentCategory, + "The first category button is selected by default" + ) + }); + + /** + * Tests that categories are selected when clicked + */ + add_task(async function test_select_category() { + let gBrowser = BrowserWindowTracker.getTopWindow().top.gBrowser; + let secondCategory = fxviewCategoryNav.categoryButtons[1]; + let categoryChanged = BrowserTestUtils.waitForEvent( + gBrowser, + "change-category" + ); + + secondCategory.buttonEl.click(); + await categoryChanged; + + ok( + secondCategory.name === fxviewCategoryNav.currentCategory, + "The second category button is selected" + ) + + let thirdCategory = fxviewCategoryNav.categoryButtons[2]; + categoryChanged = BrowserTestUtils.waitForEvent( + gBrowser, + "change-category" + ); + + thirdCategory.buttonEl.click(); + await categoryChanged; + + ok( + thirdCategory.name === fxviewCategoryNav.currentCategory, + "The third category button is selected" + ) + + let firstCategory = fxviewCategoryNav.categoryButtons[0]; + categoryChanged = BrowserTestUtils.waitForEvent( + gBrowser, + "change-category" + ); + + firstCategory.buttonEl.click(); + await categoryChanged; + + ok( + firstCategory.name === fxviewCategoryNav.currentCategory, + "The first category button is selected" + ) + }); + + /** + * Tests that categories are keyboard-navigable + */ + add_task(async function test_keyboard_navigation() { + const arrowDown = async () => { + info("Arrow down"); + synthesizeKey("KEY_ArrowDown", {}); + await fxviewCategoryNav.getUpdateComplete(); + }; + const arrowUp = async () => { + info("Arrow up"); + synthesizeKey("KEY_ArrowUp", {}); + await fxviewCategoryNav.getUpdateComplete(); + }; + const arrowLeft = async () => { + info("Arrow left"); + synthesizeKey("KEY_ArrowLeft", {}); + await fxviewCategoryNav.getUpdateComplete(); + }; + const arrowRight = async () => { + info("Arrow right"); + synthesizeKey("KEY_ArrowRight", {}); + await fxviewCategoryNav.getUpdateComplete(); + }; + + // Setting this pref allows the test to run as expected with a keyboard on MacOS + await SpecialPowers.pushPrefEnv({ + set: [["accessibility.tabfocus", 7]], + }); + + let firstCategory = fxviewCategoryNav.categoryButtons[0]; + let secondCategory = fxviewCategoryNav.categoryButtons[1]; + let thirdCategory = fxviewCategoryNav.categoryButtons[2]; + let fourthCategory = fxviewCategoryNav.categoryButtons[3]; + let fifthCategory = fxviewCategoryNav.categoryButtons[4]; + + is( + firstCategory.name, + fxviewCategoryNav.currentCategory, + "The first category button is selected" + ) + firstCategory.focus(); + await arrowDown(); + ok( + isActiveElement(secondCategory), + "The second category button is the active element after first arrow down" + ); + is( + secondCategory.name, + fxviewCategoryNav.currentCategory, + "The second category button is selected" + ) + await arrowDown(); + is( + thirdCategory.name, + fxviewCategoryNav.currentCategory, + "The third category button is selected" + ) + await arrowDown(); + is( + fourthCategory.name, + fxviewCategoryNav.currentCategory, + "The fourth category button is selected" + ) + await arrowDown(); + is( + fifthCategory.name, + fxviewCategoryNav.currentCategory, + "The fifth category button is selected" + ) + await arrowDown(); + is( + fifthCategory.name, + fxviewCategoryNav.currentCategory, + "The fifth category button is still selected" + ) + await arrowUp(); + is( + fourthCategory.name, + fxviewCategoryNav.currentCategory, + "The fourth category button is selected" + ) + await arrowUp(); + is( + thirdCategory.name, + fxviewCategoryNav.currentCategory, + "The third category button is selected" + ) + await arrowUp(); + is( + secondCategory.name, + fxviewCategoryNav.currentCategory, + "The second category button is selected" + ) + await arrowUp(); + is( + firstCategory.name, + fxviewCategoryNav.currentCategory, + "The first category button is selected" + ) + await arrowUp(); + is( + firstCategory.name, + fxviewCategoryNav.currentCategory, + "The first category button is still selected" + ) + + // Test navigation with arrow left/right keys + is( + firstCategory.name, + fxviewCategoryNav.currentCategory, + "The first category button is selected" + ) + firstCategory.focus(); + await arrowRight(); + ok( + isActiveElement(secondCategory), + "The second category button is the active element after first arrow right" + ); + is( + secondCategory.name, + fxviewCategoryNav.currentCategory, + "The second category button is selected" + ) + await arrowRight(); + is( + thirdCategory.name, + fxviewCategoryNav.currentCategory, + "The third category button is selected" + ) + await arrowRight(); + is( + fourthCategory.name, + fxviewCategoryNav.currentCategory, + "The fourth category button is selected" + ) + await arrowRight(); + is( + fifthCategory.name, + fxviewCategoryNav.currentCategory, + "The fifth category button is selected" + ) + await arrowRight(); + is( + fifthCategory.name, + fxviewCategoryNav.currentCategory, + "The fifth category button is still selected" + ) + await arrowLeft(); + is( + fourthCategory.name, + fxviewCategoryNav.currentCategory, + "The fourth category button is selected" + ) + await arrowLeft(); + is( + thirdCategory.name, + fxviewCategoryNav.currentCategory, + "The third category button is selected" + ) + await arrowLeft(); + is( + secondCategory.name, + fxviewCategoryNav.currentCategory, + "The second category button is selected" + ) + await arrowLeft(); + is( + firstCategory.name, + fxviewCategoryNav.currentCategory, + "The first category button is selected" + ) + await arrowLeft(); + is( + firstCategory.name, + fxviewCategoryNav.currentCategory, + "The first category button is still selected" + ) + + await SpecialPowers.popPrefEnv(); + }); +</script> +</body> +</html> diff --git a/browser/components/firefoxview/tests/chrome/test_fxview_tab_list.html b/browser/components/firefoxview/tests/chrome/test_fxview_tab_list.html new file mode 100644 index 0000000000..92a645c431 --- /dev/null +++ b/browser/components/firefoxview/tests/chrome/test_fxview_tab_list.html @@ -0,0 +1,465 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>FxviewTabList Tests</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <link rel="localization" href="browser/places.ftl"> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css"> + <script type="module" src="chrome://browser/content/firefoxview/fxview-tab-list.mjs"></script> +</head> +<body> + <style> + fxview-tab-list.history::part(secondary-button) { + background-image: url("chrome://global/skin/icons/more.svg"); + } + </style> +<p id="display"></p> +<div id="content" style="max-width: 750px"> + <fxview-tab-list class="history" .dateTimeFormat="relative" .hasPopup="menu"> + <panel-list slot="menu"> + <panel-item data-l10n-id="fxviewtabrow-delete"></panel-item> + <panel-item data-l10n-id="fxviewtabrow-forget-about-this-site"></panel-item> + <hr /> + <panel-item data-l10n-id="fxviewtabrow-open-in-window"></panel-item> + <panel-item data-l10n-id="fxviewtabrow-open-in-private-window"></panel-item> + <hr /> + <panel-item data-l10n-id="fxviewtabrow-add-bookmark"></panel-item> + <panel-item data-l10n-id="fxviewtabrow-save-to-pocket"></panel-item> + <panel-item data-l10n-id="fxviewtabrow-copy-link"></panel-item> + </panel-list> + </fxview-tab-list> +</div> +<pre id="test"> +<script class="testbody" type="application/javascript"> + Services.scriptloader.loadSubScript( + "chrome://browser/content/utilityOverlay.js", + this + ); + + const { BrowserTestUtils } = ChromeUtils.import( + "resource://testing-common/BrowserTestUtils.jsm" + ); + const { PlacesQuery } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesQuery.sys.mjs" + ); + const { PromiseUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PromiseUtils.sys.mjs" + ); + const { PlacesUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesUtils.sys.mjs" + ); + const { PlacesUIUtils } = ChromeUtils.importESModule( + "resource:///modules/PlacesUIUtils.sys.mjs" + ); + const { PlacesTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PlacesTestUtils.sys.mjs" + ); + const { TestUtils } = ChromeUtils.import( + "resource://testing-common/TestUtils.jsm" + ); + + const fxviewTabList = document.querySelector("fxview-tab-list"); + let tabItems = []; + const placesQuery = new PlacesQuery(); + + const URLs = [ + "http://mochi.test:8888/browser/", + "https://www.example.com/", + "https://example.net/", + "https://example.org/", + "https://www.mozilla.org/" + ]; + + async function addHistoryItems() { + await PlacesUtils.history.clear(); + let history = await placesQuery.getHistory(); + + const now = new Date(); + await PlacesUtils.history.insert({ + url: URLs[0], + title: "Example Domain 1", + visits: [{ date: now }], + }); + let historyUpdated = PromiseUtils.defer(); + placesQuery.observeHistory(newHistory => { + history = newHistory; + historyUpdated.resolve(); + }); + await PlacesUtils.history.insert({ + url: URLs[1], + title: "Example Domain 2", + visits: [{ date: now }], + }); + await historyUpdated.promise; + historyUpdated = PromiseUtils.defer(); + placesQuery.observeHistory(newHistory => { + history = newHistory; + historyUpdated.resolve(); + }); + await PlacesUtils.history.insert({ + url: URLs[2], + title: "Example Domain 3", + visits: [{ date: now }], + }); + await historyUpdated.promise; + historyUpdated = PromiseUtils.defer(); + placesQuery.observeHistory(newHistory => { + history = newHistory; + historyUpdated.resolve(); + }); + await PlacesUtils.history.insert({ + url: URLs[3], + title: "Example Domain 4", + visits: [{ date: now }], + }); + await historyUpdated.promise; + + let normalized = normalizeHistoryData(history); + fxviewTabList.tabItems = normalized; + + await fxviewTabList.getUpdateComplete(); + tabItems = Array.from(fxviewTabList.rowEls); + } + + function normalizeHistoryData(history) { + history.forEach(historyItem => { + historyItem.time = historyItem.date.getTime(); + historyItem.icon = `page-icon:${historyItem.url}`; + historyItem.primaryL10nId = "fxviewtabrow-tabs-list-tab"; + historyItem.primaryL10nArgs = JSON.stringify({ targetURI: historyItem.url }); + historyItem.secondaryL10nId = "fxviewtabrow-open-menu-button"; + }); + return history; + } + + function getCurrentDisplayDate() { + let lastItemMainEl = tabItems[tabItems.length - 1].mainEl; + return lastItemMainEl.querySelector("#fxview-tab-row-date span:not([hidden])")?.textContent.trim() ?? ""; + } + + function getCurrentDisplayTime() { + let lastItemMainEl = tabItems[tabItems.length - 1].mainEl; + return lastItemMainEl.querySelector("#fxview-tab-row-time")?.textContent.trim() ?? ""; + } + + function isActiveElement(expectedLinkEl) { + return expectedLinkEl.getRootNode().activeElement == expectedLinkEl; + } + + function onPrimaryAction(e) { + let gBrowser = BrowserWindowTracker.getTopWindow().top.gBrowser; + gBrowser.addTrustedTab(e.originalTarget.url); + } + + function onSecondaryAction(e) { + e.target.querySelector("panel-list").toggle(e.detail.originalEvent); + } + + add_setup(function setup() { + fxviewTabList.addEventListener("fxview-tab-list-primary-action", onPrimaryAction); + fxviewTabList.addEventListener("fxview-tab-list-secondary-action", onSecondaryAction); + }); + + /** + * Tests that history items are loaded in the expected order + */ + add_task(async function test_list_ordering() { + await addHistoryItems(); + is( + tabItems.length, + 4, + "Four history items are shown in the list." + ); + + // Check ordering + ok( + tabItems[0].title === "Example Domain 4", + "First history item in fxview-tab-list is in the correct order." + ) + + ok( + tabItems[3].title === "Example Domain 1", + "Last history item in fxview-tab-list is in the correct order." + ) + }); + + /** + * Tests the primary action function is triggered when selecting the main row element + */ + add_task(async function test_primary_action(){ + await addHistoryItems(); + let gBrowser = BrowserWindowTracker.getTopWindow().top.gBrowser; + let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, tabItems[0].url); + tabItems[0].mainEl.click(); + await newTabPromise; + + is( + tabItems.length, + 4, + "Four history items are still shown in the list." + ); + + await BrowserTestUtils.removeTab(gBrowser.tabs[gBrowser.tabs.length - 1]); + }); + + /** + * Tests that a max tabs length value can be given to fxview-tab-list + */ + add_task(async function test_max_list_items() { + const mockMaxTabsLength = 3; + + // override this value for testing purposes + fxviewTabList.maxTabsLength = mockMaxTabsLength; + await addHistoryItems(); + + is( + tabItems.length, + mockMaxTabsLength, + `fxview-tabs-list should have ${mockMaxTabsLength} list items` + ); + + // Add new history items + let history = await placesQuery.getHistory(); + + const now = new Date(); + await PlacesUtils.history.insert({ + url: URLs[4], + title: "Internet for people, not profits - Mozilla", + visits: [{ date: now }], + }); + let historyUpdated = PromiseUtils.defer(); + placesQuery.observeHistory(newHistory => { + history = newHistory; + historyUpdated.resolve(); + }); + await historyUpdated.promise; + + ok( + history.length === 5, + "Five total history items after inserting another node" + ); + + // Update fxview-tab-list component with latest history data + let normalized = normalizeHistoryData(history); + fxviewTabList.tabItems = normalized; + await fxviewTabList.getUpdateComplete(); + tabItems = Array.from(fxviewTabList.rowEls); + + is( + tabItems.length, + mockMaxTabsLength, + `fxview-tabs-list should have ${mockMaxTabsLength} list items` + ); + + ok( + tabItems[0].title === "Internet for people, not profits - Mozilla", + "History list has been updated with the expected maxTabsLength." + ) + fxviewTabList.maxTabsLength = 25; + }); + + /** + * Tests keyboard navigation of the fxview-tab-list component + */ + add_task(async function test_keyboard_navigation() { + const arrowDown = async () => { + info("Arrow down"); + synthesizeKey("KEY_ArrowDown", {}); + await fxviewTabList.getUpdateComplete(); + }; + const arrowUp = async () => { + info("Arrow up"); + synthesizeKey("KEY_ArrowUp", {}); + await fxviewTabList.getUpdateComplete(); + }; + const arrowRight = async () => { + info("Arrow right"); + synthesizeKey("KEY_ArrowRight", {}); + await fxviewTabList.getUpdateComplete(); + }; + const arrowLeft = async () => { + info("Arrow left"); + synthesizeKey("KEY_ArrowLeft", {}); + await fxviewTabList.getUpdateComplete(); + }; + + await addHistoryItems(); + tabItems[0].mainEl.focus(); + ok( + isActiveElement(tabItems[0].mainEl), + "Focus should be on the first main element of the list" + ); + + // Arrow down/up the list + await arrowDown(); + ok( + isActiveElement(tabItems[1].mainEl), + "Focus should be on the second main element of the list" + ); + await arrowDown(); + ok( + isActiveElement(tabItems[2].mainEl), + "Focus should be on the third main element of the list" + ); + await arrowDown(); + ok( + isActiveElement(tabItems[3].mainEl), + "Focus should be on the fourth main element of the list" + ); + await arrowUp(); + ok( + isActiveElement(tabItems[2].mainEl), + "Focus should be on the third main element of the list" + ); + await arrowUp(); + ok( + isActiveElement(tabItems[1].mainEl), + "Focus should be on the second main element of the list" + ); + await arrowUp(); + ok( + isActiveElement(tabItems[0].mainEl), + "Focus should be on the first main element of the list" + ); + await arrowRight(); + ok( + isActiveElement(tabItems[0].buttonEl), + "Focus should be on the first row's context menu button element of the list" + ); + await arrowDown(); + ok( + isActiveElement(tabItems[1].buttonEl), + "Focus should be on the second row's context menu button element of the list" + ); + await arrowLeft(); + ok( + isActiveElement(tabItems[1].mainEl), + "Focus should be on the second main element of the list" + ); + await arrowUp(); + ok( + isActiveElement(tabItems[0].mainEl), + "Focus should be on the first main element of the list" + ); + }); + + /** + * Tests relative time format for the fxview-tab-list component + */ + add_task(async function test_relative_format() { + await addHistoryItems(); + ok( + getCurrentDisplayDate().includes("Just now"), + "Current dateTime format is 'relative' and date displays 'Just now' initially" + ); + ok( + !getCurrentDisplayTime().length, + "Current dateTime format is 'relative' and time displays an empty string" + ); + }); + + /** + * Tests date only format for the fxview-tab-list component + */ + add_task(async function test_date_only_format() { + await addHistoryItems(); + + // Check date only format + fxviewTabList.dateTimeFormat = "date"; + await fxviewTabList.getUpdateComplete(); + await BrowserTestUtils.waitForCondition(() => { + return getCurrentDisplayDate().includes("/"); + }); + ok( + getCurrentDisplayDate().includes("/"), + "Current dateTime format is 'date' and displays the current date" + ); + ok( + !getCurrentDisplayTime().length, + "Current dateTime format is 'date' and time displays an empty string" + ); + }); + + /** + * Tests time only format for the fxview-tab-list component + */ + add_task(async function test_time_only_format() { + await addHistoryItems(); + + // Check time only format + fxviewTabList.dateTimeFormat = "time"; + await fxviewTabList.getUpdateComplete(); + await BrowserTestUtils.waitForCondition(() => { + return getCurrentDisplayTime().includes("AM") || getCurrentDisplayTime().includes("PM"); + }); + ok( + !getCurrentDisplayDate().length, + "Current dateTime format is 'time' and date displays an empty string" + ); + ok( + getCurrentDisplayTime().includes("AM") || getCurrentDisplayTime().includes("PM"), + "Current dateTime format is 'time' and displays the current time" + ); + }); + + /** + * Tests date and time format for the fxview-tab-list component + */ + add_task(async function test_date_and_time_format() { + await addHistoryItems(); + + // Check date and time format + fxviewTabList.dateTimeFormat = "dateTime"; + await fxviewTabList.getUpdateComplete(); + await BrowserTestUtils.waitForCondition(() => { + return getCurrentDisplayDate().includes("/") && + (getCurrentDisplayTime().includes("AM") || getCurrentDisplayTime().includes("PM")); + }); + ok( + getCurrentDisplayDate().includes("/"), + "Current dateTime format is 'dateTime' and date displays the current date" + ); + ok( + getCurrentDisplayTime().includes("AM") || getCurrentDisplayTime().includes("PM"), + "Current dateTime format is 'dateTime' and displays the current time" + ); + + // Reset dateTimeFormat to relative before next test + fxviewTabList.dateTimeFormat = "relative"; + await fxviewTabList.getUpdateComplete(); + }); + + /** + * Tests that relative time updates properly for the fxview-tab-list component + */ + add_task(async function test_relative_time_updates() { + await addHistoryItems(); + + await BrowserTestUtils.waitForCondition(() => { + return getCurrentDisplayDate().includes("Just now"); + }); + + ok( + getCurrentDisplayDate().includes("Just now"), + "Current date element displays 'Just now' initially" + ); + + // Set the updateTimeMs pref to something low to check that relative time updates properly + const TAB_UPDATE_TIME_MS = 500; + await SpecialPowers.pushPrefEnv({ + set: [["browser.tabs.firefox-view.updateTimeMs", TAB_UPDATE_TIME_MS]], + }); + await BrowserTestUtils.waitForCondition(() => { + return !getCurrentDisplayDate().includes("now"); + }); + info("Currently displayed date is something other than 'Just now'"); + + await SpecialPowers.popPrefEnv(); + }); +</script> +</pre> +</body> +</html> diff --git a/browser/components/firefoxview/viewpage.mjs b/browser/components/firefoxview/viewpage.mjs new file mode 100644 index 0000000000..e7b788b0ca --- /dev/null +++ b/browser/components/firefoxview/viewpage.mjs @@ -0,0 +1,34 @@ +/* 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/. */ + +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; + +export class ViewPage extends MozLitElement { + static get properties() { + return { + selectedTab: { type: Boolean }, + overview: { type: Boolean }, + }; + } + + constructor() { + super(); + this.selectedTab = false; + this.overview = Boolean(this.closest("VIEW-OVERVIEW")); + } + + connectedCallback() { + super.connectedCallback(); + } + + disconnectedCallback() {} + + enter() { + this.selectedTab = true; + } + + exit() { + this.selectedTab = false; + } +} |