From 36d22d82aa202bb199967e9512281e9a53db42c9 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 21:33:14 +0200 Subject: Adding upstream version 115.7.0esr. Signed-off-by: Daniel Baumann --- browser/components/firefoxview/card-container.css | 125 +++ browser/components/firefoxview/card-container.mjs | 93 ++ .../content/callout-tab-pickup-dark.svg | 4 + .../firefoxview/content/callout-tab-pickup.svg | 4 + .../firefoxview/content/cfr-lightning-dark.svg | 6 + .../firefoxview/content/cfr-lightning.svg | 6 + .../firefoxview/content/recently-closed-empty.svg | 12 + .../firefoxview/content/tab-pickup-empty.svg | 18 + .../firefox-view-notification-manager.sys.mjs | 115 +++ .../firefox-view-synced-tabs-error-handler.sys.mjs | 187 ++++ .../firefox-view-tabs-setup-manager.sys.mjs | 667 +++++++++++++++ .../components/firefoxview/firefoxview-next.css | 86 ++ .../components/firefoxview/firefoxview-next.html | 96 +++ .../components/firefoxview/firefoxview-next.mjs | 62 ++ browser/components/firefoxview/firefoxview.css | 951 +++++++++++++++++++++ browser/components/firefoxview/firefoxview.html | 341 ++++++++ browser/components/firefoxview/firefoxview.mjs | 43 + .../firefoxview/fxview-category-button.css | 92 ++ .../firefoxview/fxview-category-navigation.css | 64 ++ .../firefoxview/fxview-category-navigation.mjs | 149 ++++ browser/components/firefoxview/fxview-tab-list.css | 9 + browser/components/firefoxview/fxview-tab-list.mjs | 493 +++++++++++ browser/components/firefoxview/fxview-tab-row.css | 135 +++ browser/components/firefoxview/helpers.mjs | 103 +++ browser/components/firefoxview/history.mjs | 45 + browser/components/firefoxview/jar.mn | 33 + browser/components/firefoxview/moz.build | 22 + browser/components/firefoxview/opentabs.mjs | 37 + browser/components/firefoxview/overview.mjs | 35 + .../firefoxview/recently-closed-tabs.mjs | 463 ++++++++++ .../firefoxview/tab-pickup-container.mjs | 295 +++++++ browser/components/firefoxview/tab-pickup-list.mjs | 417 +++++++++ .../tests/browser/FirefoxViewTestUtils.sys.mjs | 122 +++ .../firefoxview/tests/browser/browser.ini | 37 + .../tests/browser/browser_cfr_message.js | 67 ++ .../browser_dragDrop_after_opening_fxViewTab.js | 120 +++ .../tests/browser/browser_entrypoint_management.js | 67 ++ .../tests/browser/browser_feature_callout.js | 771 +++++++++++++++++ .../browser/browser_feature_callout_position.js | 402 +++++++++ .../browser/browser_feature_callout_resize.js | 127 +++ .../browser/browser_feature_callout_targeting.js | 171 ++++ .../tests/browser/browser_feature_callout_theme.js | 79 ++ .../tests/browser/browser_firefoxview.js | 18 + .../browser/browser_firefoxview_accessibility.js | 110 +++ .../browser_firefoxview_feature_callout_a11y.js | 55 ++ .../tests/browser/browser_firefoxview_tab.js | 308 +++++++ .../tests/browser/browser_keyboard_focus.js | 91 ++ .../tests/browser/browser_notification_dot.js | 389 +++++++++ .../tests/browser/browser_recently_closed_tabs.js | 886 +++++++++++++++++++ .../browser_recently_closed_tabs_keyboard.js | 269 ++++++ .../tests/browser/browser_reload_firefoxview.js | 36 + .../tests/browser/browser_setup_errors.js | 370 ++++++++ .../browser/browser_setup_primary_password.js | 145 ++++ .../tests/browser/browser_setup_state.js | 769 +++++++++++++++++ .../browser/browser_setup_synced_tabs_loading.js | 176 ++++ .../tests/browser/browser_sync_admin_disabled.js | 68 ++ .../tests/browser/browser_tab_close_last_tab.js | 43 + .../tests/browser/browser_tab_on_close_warning.js | 60 ++ .../browser_tab_pickup_device_added_telemetry.js | 278 ++++++ .../tests/browser/browser_tab_pickup_list.js | 794 +++++++++++++++++ .../tests/browser/browser_tab_pickup_visibility.js | 149 ++++ .../firefoxview/tests/browser/browser_ui_state.js | 141 +++ .../components/firefoxview/tests/browser/head.js | 550 ++++++++++++ .../components/firefoxview/tests/chrome/chrome.ini | 5 + .../tests/chrome/test_card_container.html | 122 +++ .../chrome/test_fxview_category_navigation.html | 322 +++++++ .../tests/chrome/test_fxview_tab_list.html | 465 ++++++++++ browser/components/firefoxview/viewpage.mjs | 34 + 68 files changed, 13824 insertions(+) create mode 100644 browser/components/firefoxview/card-container.css create mode 100644 browser/components/firefoxview/card-container.mjs create mode 100644 browser/components/firefoxview/content/callout-tab-pickup-dark.svg create mode 100644 browser/components/firefoxview/content/callout-tab-pickup.svg create mode 100644 browser/components/firefoxview/content/cfr-lightning-dark.svg create mode 100644 browser/components/firefoxview/content/cfr-lightning.svg create mode 100644 browser/components/firefoxview/content/recently-closed-empty.svg create mode 100644 browser/components/firefoxview/content/tab-pickup-empty.svg create mode 100644 browser/components/firefoxview/firefox-view-notification-manager.sys.mjs create mode 100644 browser/components/firefoxview/firefox-view-synced-tabs-error-handler.sys.mjs create mode 100644 browser/components/firefoxview/firefox-view-tabs-setup-manager.sys.mjs create mode 100644 browser/components/firefoxview/firefoxview-next.css create mode 100644 browser/components/firefoxview/firefoxview-next.html create mode 100644 browser/components/firefoxview/firefoxview-next.mjs create mode 100644 browser/components/firefoxview/firefoxview.css create mode 100644 browser/components/firefoxview/firefoxview.html create mode 100644 browser/components/firefoxview/firefoxview.mjs create mode 100644 browser/components/firefoxview/fxview-category-button.css create mode 100644 browser/components/firefoxview/fxview-category-navigation.css create mode 100644 browser/components/firefoxview/fxview-category-navigation.mjs create mode 100644 browser/components/firefoxview/fxview-tab-list.css create mode 100644 browser/components/firefoxview/fxview-tab-list.mjs create mode 100644 browser/components/firefoxview/fxview-tab-row.css create mode 100644 browser/components/firefoxview/helpers.mjs create mode 100644 browser/components/firefoxview/history.mjs create mode 100644 browser/components/firefoxview/jar.mn create mode 100644 browser/components/firefoxview/moz.build create mode 100644 browser/components/firefoxview/opentabs.mjs create mode 100644 browser/components/firefoxview/overview.mjs create mode 100644 browser/components/firefoxview/recently-closed-tabs.mjs create mode 100644 browser/components/firefoxview/tab-pickup-container.mjs create mode 100644 browser/components/firefoxview/tab-pickup-list.mjs create mode 100644 browser/components/firefoxview/tests/browser/FirefoxViewTestUtils.sys.mjs create mode 100644 browser/components/firefoxview/tests/browser/browser.ini create mode 100644 browser/components/firefoxview/tests/browser/browser_cfr_message.js create mode 100644 browser/components/firefoxview/tests/browser/browser_dragDrop_after_opening_fxViewTab.js create mode 100644 browser/components/firefoxview/tests/browser/browser_entrypoint_management.js create mode 100644 browser/components/firefoxview/tests/browser/browser_feature_callout.js create mode 100644 browser/components/firefoxview/tests/browser/browser_feature_callout_position.js create mode 100644 browser/components/firefoxview/tests/browser/browser_feature_callout_resize.js create mode 100644 browser/components/firefoxview/tests/browser/browser_feature_callout_targeting.js create mode 100644 browser/components/firefoxview/tests/browser/browser_feature_callout_theme.js create mode 100644 browser/components/firefoxview/tests/browser/browser_firefoxview.js create mode 100644 browser/components/firefoxview/tests/browser/browser_firefoxview_accessibility.js create mode 100644 browser/components/firefoxview/tests/browser/browser_firefoxview_feature_callout_a11y.js create mode 100644 browser/components/firefoxview/tests/browser/browser_firefoxview_tab.js create mode 100644 browser/components/firefoxview/tests/browser/browser_keyboard_focus.js create mode 100644 browser/components/firefoxview/tests/browser/browser_notification_dot.js create mode 100644 browser/components/firefoxview/tests/browser/browser_recently_closed_tabs.js create mode 100644 browser/components/firefoxview/tests/browser/browser_recently_closed_tabs_keyboard.js create mode 100644 browser/components/firefoxview/tests/browser/browser_reload_firefoxview.js create mode 100644 browser/components/firefoxview/tests/browser/browser_setup_errors.js create mode 100644 browser/components/firefoxview/tests/browser/browser_setup_primary_password.js create mode 100644 browser/components/firefoxview/tests/browser/browser_setup_state.js create mode 100644 browser/components/firefoxview/tests/browser/browser_setup_synced_tabs_loading.js create mode 100644 browser/components/firefoxview/tests/browser/browser_sync_admin_disabled.js create mode 100644 browser/components/firefoxview/tests/browser/browser_tab_close_last_tab.js create mode 100644 browser/components/firefoxview/tests/browser/browser_tab_on_close_warning.js create mode 100644 browser/components/firefoxview/tests/browser/browser_tab_pickup_device_added_telemetry.js create mode 100644 browser/components/firefoxview/tests/browser/browser_tab_pickup_list.js create mode 100644 browser/components/firefoxview/tests/browser/browser_tab_pickup_visibility.js create mode 100644 browser/components/firefoxview/tests/browser/browser_ui_state.js create mode 100644 browser/components/firefoxview/tests/browser/head.js create mode 100644 browser/components/firefoxview/tests/chrome/chrome.ini create mode 100644 browser/components/firefoxview/tests/chrome/test_card_container.html create mode 100644 browser/components/firefoxview/tests/chrome/test_fxview_category_navigation.html create mode 100644 browser/components/firefoxview/tests/chrome/test_fxview_tab_list.html create mode 100644 browser/components/firefoxview/viewpage.mjs (limited to 'browser/components/firefoxview') 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` + +
+
+ + + + + + + +
+
+ `; + } +} +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 @@ + + 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 @@ + + 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 @@ + + + + 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 @@ + + + + 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 @@ + + + + + + + + + + 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 @@ + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + +

+ + + + + + + + + + +
+
+ + +
+ +
+

+
+ +
+
+ + +
+
+ + + 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 + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+ + +

+

+
+ + + +
+ +

+
+

+ + +

+ +
+
+
+

+
+

+ +
+
+ + +
+
+
+

+
+

+ +
+ +

+ +
+
+ + +
+
+
+

+
+

+ +
+ +

+ +
+
+ + +
+
+
+ + + + +
+ +
+ + +

+

+
+
+ +
+
+
+ + 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` + + + `; + } + + 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` + + + `; + } + + 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` + +
+ ${tabItems.map( + (tabItem, i) => + html` + + + ` + )} +
+ + `; + } +} +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` + + + + + + ${title} + + + ${this.formatURIForDisplay(this.url)} + + + + ${relativeString} + + + + + + `; + } +} + +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
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`

History Row ${i}

`); + } + + return html` + +

+
    + ${itemTemplates} +
+
+ `; + } +} +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`

Open Tab Row ${i}

`); + } + + return html` +
    + ${itemTemplates} +
+ `; + } +} +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` `; + } +} +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` +
    + ${recentlyClosedTabs.map((tab, i) => + this.recentlyClosedTabTemplate(tab, !i) + )} +
+ `; + } + + 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` + + `; + } + + recentlyClosedTabTemplate(tab, primary) { + const targetURI = this.getTabStateValue(tab, "url"); + const convertedTime = convertTimestamp( + tab.closedAt, + this.fluentStrings, + lazy.timeMsPref + ); + return html` +
  • (this.contextTriggerNode = e.currentTarget)} + > + this.openTabAndUpdate(e)} + @keydown=${e => this.openTabAndUpdate(e)} + > +
    + e.preventDefault()} + > + ${tab.title} + + e.preventDefault()} + > + ${formatURIForDisplay(targetURI)} + + + ${convertedTime} + +
    + +
  • + `; + } + + // 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: + * | + */ + 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 @@ + + + + + CardContainer Tests + + + + + + + + + +

    +
    + +

    +
      +
    • History Row 1
    • +
    • History Row 2
    • +
    • History Row 3
    • +
    • History Row 4
    • +
    • History Row 5
    • +
    +
    +
    +
    +
    +
    + + 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 @@ + + + + + FxviewCategoryNavigation Tests + + + + + + + + +

    +
    + +
    +
    
    +
    +
    +
    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 @@
    +
    +
    +
    +  
    +  FxviewTabList Tests
    +  
    +  
    +  
    +  
    +  
    +  
    +
    +
    +  
    +

    +
    + + + + +
    + + +
    + + + +
    +
    +
    +
    +
    +
    + + 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; + } +} -- cgit v1.2.3