summaryrefslogtreecommitdiffstats
path: root/browser/components/firefoxview
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/firefoxview')
-rw-r--r--browser/components/firefoxview/OpenTabs.sys.mjs410
-rw-r--r--browser/components/firefoxview/card-container.css171
-rw-r--r--browser/components/firefoxview/card-container.mjs208
-rw-r--r--browser/components/firefoxview/content/callout-tab-pickup-dark.svg4
-rw-r--r--browser/components/firefoxview/content/callout-tab-pickup.svg4
-rw-r--r--browser/components/firefoxview/content/category-history.svg6
-rw-r--r--browser/components/firefoxview/content/category-opentabs.svg6
-rw-r--r--browser/components/firefoxview/content/category-recentbrowsing.svg7
-rw-r--r--browser/components/firefoxview/content/category-recentlyclosed.svg7
-rw-r--r--browser/components/firefoxview/content/category-syncedtabs.svg6
-rw-r--r--browser/components/firefoxview/content/history-empty.svg20
-rw-r--r--browser/components/firefoxview/content/recentlyclosed-empty.svg25
-rw-r--r--browser/components/firefoxview/content/synced-tabs-error.svg30
-rw-r--r--browser/components/firefoxview/firefox-view-notification-manager.sys.mjs112
-rw-r--r--browser/components/firefoxview/firefox-view-places-query.sys.mjs187
-rw-r--r--browser/components/firefoxview/firefox-view-synced-tabs-error-handler.sys.mjs187
-rw-r--r--browser/components/firefoxview/firefox-view-tabs-setup-manager.sys.mjs653
-rw-r--r--browser/components/firefoxview/firefoxview.css187
-rw-r--r--browser/components/firefoxview/firefoxview.html118
-rw-r--r--browser/components/firefoxview/firefoxview.mjs189
-rw-r--r--browser/components/firefoxview/fxview-category-button.css125
-rw-r--r--browser/components/firefoxview/fxview-category-navigation.css60
-rw-r--r--browser/components/firefoxview/fxview-category-navigation.mjs150
-rw-r--r--browser/components/firefoxview/fxview-empty-state.css99
-rw-r--r--browser/components/firefoxview/fxview-empty-state.mjs121
-rw-r--r--browser/components/firefoxview/fxview-search-textbox.css78
-rw-r--r--browser/components/firefoxview/fxview-search-textbox.mjs143
-rw-r--r--browser/components/firefoxview/fxview-tab-list.css24
-rw-r--r--browser/components/firefoxview/fxview-tab-list.mjs919
-rw-r--r--browser/components/firefoxview/fxview-tab-row.css204
-rw-r--r--browser/components/firefoxview/helpers.mjs175
-rw-r--r--browser/components/firefoxview/history.css80
-rw-r--r--browser/components/firefoxview/history.mjs656
-rw-r--r--browser/components/firefoxview/jar.mn40
-rw-r--r--browser/components/firefoxview/moz.build22
-rw-r--r--browser/components/firefoxview/opentabs.mjs834
-rw-r--r--browser/components/firefoxview/recentbrowsing.mjs65
-rw-r--r--browser/components/firefoxview/recentlyclosed.mjs473
-rw-r--r--browser/components/firefoxview/syncedtabs.mjs725
-rw-r--r--browser/components/firefoxview/tests/browser/FirefoxViewTestUtils.sys.mjs177
-rw-r--r--browser/components/firefoxview/tests/browser/browser.toml74
-rw-r--r--browser/components/firefoxview/tests/browser/browser_dragDrop_after_opening_fxViewTab.js120
-rw-r--r--browser/components/firefoxview/tests/browser/browser_entrypoint_management.js67
-rw-r--r--browser/components/firefoxview/tests/browser/browser_feature_callout.js746
-rw-r--r--browser/components/firefoxview/tests/browser/browser_feature_callout_position.js445
-rw-r--r--browser/components/firefoxview/tests/browser/browser_feature_callout_resize.js178
-rw-r--r--browser/components/firefoxview/tests/browser/browser_feature_callout_targeting.js175
-rw-r--r--browser/components/firefoxview/tests/browser/browser_feature_callout_theme.js80
-rw-r--r--browser/components/firefoxview/tests/browser/browser_firefoxview.js87
-rw-r--r--browser/components/firefoxview/tests/browser/browser_firefoxview_general_telemetry.js368
-rw-r--r--browser/components/firefoxview/tests/browser/browser_firefoxview_navigation.js96
-rw-r--r--browser/components/firefoxview/tests/browser/browser_firefoxview_paused.js407
-rw-r--r--browser/components/firefoxview/tests/browser/browser_firefoxview_search_telemetry.js629
-rw-r--r--browser/components/firefoxview/tests/browser/browser_firefoxview_tab.js370
-rw-r--r--browser/components/firefoxview/tests/browser/browser_firefoxview_virtual_list.js85
-rw-r--r--browser/components/firefoxview/tests/browser/browser_history_firefoxview.js544
-rw-r--r--browser/components/firefoxview/tests/browser/browser_notification_dot.js392
-rw-r--r--browser/components/firefoxview/tests/browser/browser_opentabs_cards.js628
-rw-r--r--browser/components/firefoxview/tests/browser/browser_opentabs_changes.js541
-rw-r--r--browser/components/firefoxview/tests/browser/browser_opentabs_firefoxview.js423
-rw-r--r--browser/components/firefoxview/tests/browser/browser_opentabs_recency.js408
-rw-r--r--browser/components/firefoxview/tests/browser/browser_opentabs_tab_indicators.js207
-rw-r--r--browser/components/firefoxview/tests/browser/browser_recentlyclosed_firefoxview.js600
-rw-r--r--browser/components/firefoxview/tests/browser/browser_reload_firefoxview.js36
-rw-r--r--browser/components/firefoxview/tests/browser/browser_syncedtabs_errors_firefoxview.js141
-rw-r--r--browser/components/firefoxview/tests/browser/browser_syncedtabs_firefoxview.js747
-rw-r--r--browser/components/firefoxview/tests/browser/browser_tab_close_last_tab.js43
-rw-r--r--browser/components/firefoxview/tests/browser/browser_tab_on_close_warning.js60
-rw-r--r--browser/components/firefoxview/tests/browser/head.js708
-rw-r--r--browser/components/firefoxview/tests/chrome/chrome.toml7
-rw-r--r--browser/components/firefoxview/tests/chrome/test_card_container.html122
-rw-r--r--browser/components/firefoxview/tests/chrome/test_fxview_category_navigation.html322
-rw-r--r--browser/components/firefoxview/tests/chrome/test_fxview_tab_list.html447
-rw-r--r--browser/components/firefoxview/triage.json31
-rw-r--r--browser/components/firefoxview/view-opentabs.css44
-rw-r--r--browser/components/firefoxview/view-syncedtabs.css118
-rw-r--r--browser/components/firefoxview/viewpage.mjs261
77 files changed, 18464 insertions, 0 deletions
diff --git a/browser/components/firefoxview/OpenTabs.sys.mjs b/browser/components/firefoxview/OpenTabs.sys.mjs
new file mode 100644
index 0000000000..ac247f5e8f
--- /dev/null
+++ b/browser/components/firefoxview/OpenTabs.sys.mjs
@@ -0,0 +1,410 @@
+/* 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 provides the means to monitor and query for tab collections against open
+ * browser windows and allow listeners to be notified of changes to those collections.
+ */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
+ EveryWindow: "resource:///modules/EveryWindow.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+
+const TAB_ATTRS_TO_WATCH = Object.freeze([
+ "attention",
+ "image",
+ "label",
+ "muted",
+ "soundplaying",
+ "titlechanged",
+]);
+const TAB_CHANGE_EVENTS = Object.freeze([
+ "TabAttrModified",
+ "TabClose",
+ "TabMove",
+ "TabOpen",
+ "TabPinned",
+ "TabUnpinned",
+]);
+const TAB_RECENCY_CHANGE_EVENTS = Object.freeze([
+ "activate",
+ "TabAttrModified",
+ "TabClose",
+ "TabOpen",
+ "TabSelect",
+ "TabAttrModified",
+]);
+
+// Debounce tab/tab recency changes and dispatch max once per frame at 60fps
+const CHANGES_DEBOUNCE_MS = 1000 / 60;
+
+/**
+ * A sort function used to order tabs by most-recently seen and active.
+ */
+export function lastSeenActiveSort(a, b) {
+ let dt = b.lastSeenActive - a.lastSeenActive;
+ if (dt) {
+ return dt;
+ }
+ // try to break a deadlock by sorting the selected tab higher
+ if (!(a.selected || b.selected)) {
+ return 0;
+ }
+ return a.selected ? -1 : 1;
+}
+
+/**
+ * Provides a object capable of monitoring and accessing tab collections for either
+ * private or non-private browser windows. As the class extends EventTarget, consumers
+ * should add event listeners for the change events.
+ *
+ * @param {boolean} options.usePrivateWindows
+ Constrain to only windows that match this privateness. Defaults to false.
+ * @param {Window | null} options.exclusiveWindow
+ * Constrain to only a specific window.
+ */
+class OpenTabsTarget extends EventTarget {
+ #changedWindowsByType = {
+ TabChange: new Set(),
+ TabRecencyChange: new Set(),
+ };
+ #dispatchChangesTask;
+ #started = false;
+ #watchedWindows = new Set();
+
+ #exclusiveWindowWeakRef = null;
+ usePrivateWindows = false;
+
+ constructor(options = {}) {
+ super();
+ this.usePrivateWindows = !!options.usePrivateWindows;
+
+ if (options.exclusiveWindow) {
+ this.exclusiveWindow = options.exclusiveWindow;
+ this.everyWindowCallbackId = `opentabs-${this.exclusiveWindow.windowGlobalChild.innerWindowId}`;
+ } else {
+ this.everyWindowCallbackId = `opentabs-${
+ this.usePrivateWindows ? "private" : "non-private"
+ }`;
+ }
+ }
+
+ get exclusiveWindow() {
+ return this.#exclusiveWindowWeakRef?.get();
+ }
+ set exclusiveWindow(newValue) {
+ if (newValue) {
+ this.#exclusiveWindowWeakRef = Cu.getWeakReference(newValue);
+ } else {
+ this.#exclusiveWindowWeakRef = null;
+ }
+ }
+
+ includeWindowFilter(win) {
+ if (this.#exclusiveWindowWeakRef) {
+ return win == this.exclusiveWindow;
+ }
+ return (
+ win.gBrowser &&
+ !win.closed &&
+ this.usePrivateWindows == lazy.PrivateBrowsingUtils.isWindowPrivate(win)
+ );
+ }
+
+ get currentWindows() {
+ return lazy.EveryWindow.readyWindows.filter(win =>
+ this.includeWindowFilter(win)
+ );
+ }
+
+ /**
+ * A promise that resolves to all matched windows once their delayedStartupPromise resolves
+ */
+ get readyWindowsPromise() {
+ let windowList = Array.from(
+ Services.wm.getEnumerator("navigator:browser")
+ ).filter(win => {
+ // avoid waiting for windows we definitely don't care about
+ if (this.#exclusiveWindowWeakRef) {
+ return this.exclusiveWindow == win;
+ }
+ return (
+ this.usePrivateWindows == lazy.PrivateBrowsingUtils.isWindowPrivate(win)
+ );
+ });
+ return Promise.allSettled(
+ windowList.map(win => win.delayedStartupPromise)
+ ).then(() => {
+ // re-filter the list as properties might have changed in the interim
+ return windowList.filter(win => this.includeWindowFilter);
+ });
+ }
+
+ haveListenersForEvent(eventType) {
+ switch (eventType) {
+ case "TabChange":
+ return Services.els.hasListenersFor(this, "TabChange");
+ case "TabRecencyChange":
+ return Services.els.hasListenersFor(this, "TabRecencyChange");
+ default:
+ return false;
+ }
+ }
+
+ get haveAnyListeners() {
+ return (
+ this.haveListenersForEvent("TabChange") ||
+ this.haveListenersForEvent("TabRecencyChange")
+ );
+ }
+
+ /*
+ * @param {string} type
+ * Either "TabChange" or "TabRecencyChange"
+ * @param {Object|Function} listener
+ * @param {Object} [options]
+ */
+ addEventListener(type, listener, options) {
+ let hadListeners = this.haveAnyListeners;
+ super.addEventListener(type, listener, options);
+
+ // if this is the first listener, start up all the window & tab monitoring
+ if (!hadListeners && this.haveAnyListeners) {
+ this.start();
+ }
+ }
+
+ /*
+ * @param {string} type
+ * Either "TabChange" or "TabRecencyChange"
+ * @param {Object|Function} listener
+ */
+ removeEventListener(type, listener) {
+ let hadListeners = this.haveAnyListeners;
+ super.removeEventListener(type, listener);
+
+ // if this was the last listener, we can stop all the window & tab monitoring
+ if (hadListeners && !this.haveAnyListeners) {
+ this.stop();
+ }
+ }
+
+ /**
+ * Begin watching for tab-related events from all browser windows matching the instance's private property
+ */
+ start() {
+ if (this.#started) {
+ return;
+ }
+ // EveryWindow will call #watchWindow for each open window once its delayedStartupPromise resolves.
+ lazy.EveryWindow.registerCallback(
+ this.everyWindowCallbackId,
+ win => this.#watchWindow(win),
+ win => this.#unwatchWindow(win)
+ );
+ this.#started = true;
+ }
+
+ /**
+ * Stop watching for tab-related events from all browser windows and clean up.
+ */
+ stop() {
+ if (this.#started) {
+ lazy.EveryWindow.unregisterCallback(this.everyWindowCallbackId);
+ this.#started = false;
+ }
+ for (let changedWindows of Object.values(this.#changedWindowsByType)) {
+ changedWindows.clear();
+ }
+ this.#watchedWindows.clear();
+ this.#dispatchChangesTask?.disarm();
+ }
+
+ /**
+ * Add listeners for tab-related events from the given window. The consumer's
+ * listeners will always be notified at least once for newly-watched window.
+ */
+ #watchWindow(win) {
+ if (!this.includeWindowFilter(win)) {
+ return;
+ }
+ this.#watchedWindows.add(win);
+ const { tabContainer } = win.gBrowser;
+ tabContainer.addEventListener("TabAttrModified", this);
+ tabContainer.addEventListener("TabClose", this);
+ tabContainer.addEventListener("TabMove", this);
+ tabContainer.addEventListener("TabOpen", this);
+ tabContainer.addEventListener("TabPinned", this);
+ tabContainer.addEventListener("TabUnpinned", this);
+ tabContainer.addEventListener("TabSelect", this);
+ win.addEventListener("activate", this);
+
+ this.#scheduleEventDispatch("TabChange", {});
+ this.#scheduleEventDispatch("TabRecencyChange", {});
+ }
+
+ /**
+ * Remove all listeners for tab-related events from the given window.
+ * Consumers will always be notified at least once for unwatched window.
+ */
+ #unwatchWindow(win) {
+ // We check the window is in our watchedWindows collection rather than currentWindows
+ // as the unwatched window may not match the criteria we used to watch it anymore,
+ // and we need to unhook our event listeners regardless.
+ if (this.#watchedWindows.has(win)) {
+ this.#watchedWindows.delete(win);
+
+ const { tabContainer } = win.gBrowser;
+ tabContainer.removeEventListener("TabAttrModified", this);
+ tabContainer.removeEventListener("TabClose", this);
+ tabContainer.removeEventListener("TabMove", this);
+ tabContainer.removeEventListener("TabOpen", this);
+ tabContainer.removeEventListener("TabPinned", this);
+ tabContainer.removeEventListener("TabSelect", this);
+ tabContainer.removeEventListener("TabUnpinned", this);
+ win.removeEventListener("activate", this);
+
+ this.#scheduleEventDispatch("TabChange", {});
+ this.#scheduleEventDispatch("TabRecencyChange", {});
+ }
+ }
+
+ /**
+ * Flag the need to notify all our consumers of a change to open tabs.
+ * Repeated calls within approx 16ms will be consolidated
+ * into one event dispatch.
+ */
+ #scheduleEventDispatch(eventType, { sourceWindowId } = {}) {
+ if (!this.haveListenersForEvent(eventType)) {
+ return;
+ }
+
+ this.#changedWindowsByType[eventType].add(sourceWindowId);
+ // Queue up an event dispatch - we use a deferred task to make this less noisy by
+ // consolidating multiple change events into one.
+ if (!this.#dispatchChangesTask) {
+ this.#dispatchChangesTask = new lazy.DeferredTask(() => {
+ this.#dispatchChanges();
+ }, CHANGES_DEBOUNCE_MS);
+ }
+ this.#dispatchChangesTask.arm();
+ }
+
+ #dispatchChanges() {
+ this.#dispatchChangesTask?.disarm();
+ for (let [eventType, changedWindowIds] of Object.entries(
+ this.#changedWindowsByType
+ )) {
+ if (this.haveListenersForEvent(eventType) && changedWindowIds.size) {
+ this.dispatchEvent(
+ new CustomEvent(eventType, {
+ detail: {
+ windowIds: [...changedWindowIds],
+ },
+ })
+ );
+ changedWindowIds.clear();
+ }
+ }
+ }
+
+ /*
+ * @param {Window} win
+ * @param {boolean} sortByRecency
+ * @returns {Array<Tab>}
+ * The list of visible tabs for the browser window
+ */
+ getTabsForWindow(win, sortByRecency = false) {
+ if (this.currentWindows.includes(win)) {
+ const { visibleTabs } = win.gBrowser;
+ return sortByRecency
+ ? visibleTabs.toSorted(lastSeenActiveSort)
+ : [...visibleTabs];
+ }
+ return [];
+ }
+
+ /*
+ * @returns {Array<Tab>}
+ * A by-recency-sorted, aggregated list of tabs from all the same-privateness browser windows.
+ */
+ getRecentTabs() {
+ const tabs = [];
+ for (let win of this.currentWindows) {
+ tabs.push(...this.getTabsForWindow(win));
+ }
+ tabs.sort(lastSeenActiveSort);
+ return tabs;
+ }
+
+ handleEvent({ detail, target, type }) {
+ const win = target.ownerGlobal;
+ // NOTE: we already filtered on privateness by not listening for those events
+ // from private/not-private windows
+ if (
+ type == "TabAttrModified" &&
+ !detail.changed.some(attr => TAB_ATTRS_TO_WATCH.includes(attr))
+ ) {
+ return;
+ }
+
+ if (TAB_RECENCY_CHANGE_EVENTS.includes(type)) {
+ this.#scheduleEventDispatch("TabRecencyChange", {
+ sourceWindowId: win.windowGlobalChild.innerWindowId,
+ });
+ }
+ if (TAB_CHANGE_EVENTS.includes(type)) {
+ this.#scheduleEventDispatch("TabChange", {
+ sourceWindowId: win.windowGlobalChild.innerWindowId,
+ });
+ }
+ }
+}
+
+const gExclusiveWindows = new (class {
+ perWindowInstances = new WeakMap();
+ constructor() {
+ Services.obs.addObserver(this, "domwindowclosed");
+ }
+ observe(subject, topic, data) {
+ let win = subject;
+ let winTarget = this.perWindowInstances.get(win);
+ if (winTarget) {
+ winTarget.stop();
+ this.perWindowInstances.delete(win);
+ }
+ }
+})();
+
+/**
+ * Get an OpenTabsTarget instance constrained to a specific window.
+ *
+ * @param {Window} exclusiveWindow
+ * @returns {OpenTabsTarget}
+ */
+const getTabsTargetForWindow = function (exclusiveWindow) {
+ let instance = gExclusiveWindows.perWindowInstances.get(exclusiveWindow);
+ if (instance) {
+ return instance;
+ }
+ instance = new OpenTabsTarget({
+ exclusiveWindow,
+ });
+ gExclusiveWindows.perWindowInstances.set(exclusiveWindow, instance);
+ return instance;
+};
+
+const NonPrivateTabs = new OpenTabsTarget({
+ usePrivateWindows: false,
+});
+
+const PrivateTabs = new OpenTabsTarget({
+ usePrivateWindows: true,
+});
+
+export { NonPrivateTabs, PrivateTabs, getTabsTargetForWindow };
diff --git a/browser/components/firefoxview/card-container.css b/browser/components/firefoxview/card-container.css
new file mode 100644
index 0000000000..953437bec1
--- /dev/null
+++ b/browser/components/firefoxview/card-container.css
@@ -0,0 +1,171 @@
+/* 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);
+
+ &[isOpenTabsView] {
+ margin-block-end: 0;
+ }
+}
+
+@media (prefers-contrast) {
+ .card-container {
+ border: 1px solid CanvasText;
+ }
+}
+
+.card-container-header {
+ display: inline-flex;
+ gap: 14px;
+ width: 100%;
+ align-items: center;
+ cursor: pointer;
+ border-radius: 1px;
+ outline-offset: 4px;
+ padding: 6px;
+ padding-inline-end: 0;
+ margin-block-end: 6px;
+ height: 24px;
+}
+
+.card-container-header[withViewAll] {
+ width: 83%;
+}
+
+.card-container-header[hidden] {
+ display: none;
+}
+
+.card-container-header[toggleDisabled] {
+ cursor: auto;
+}
+
+.view-all-link {
+ color: var(--fxview-primary-action-background);
+ float: inline-end;
+ outline-offset: 6px;
+ border-radius: 1px;
+ width: 12%;
+ text-align: end;
+ padding: 6px;
+ padding-inline-start: 0;
+}
+
+.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([slot=header]),
+::slotted([slot=secondary-header]) {
+ align-self: center;
+ margin: 0;
+ font-size: 1.13em;
+ font-weight: 600;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ user-select: none;
+ white-space: nowrap;
+}
+
+::slotted([slot=header]) {
+ flex: 1;
+ width: 0;
+}
+
+::slotted([slot=secondary-header]) {
+ padding-inline-end: 1em;
+}
+
+.card-container-footer {
+ display: flex;
+ justify-content: center;
+ color: var(--fxview-primary-action-background);
+ cursor: pointer;
+}
+
+::slotted([slot=footer]:not([hidden])) {
+ text-decoration: underline;
+ display: inline-block;
+ outline-offset: 2px;
+ border-radius: 2px;
+ margin-block: 0.5rem;
+}
+
+@media (max-width: 39rem) {
+ .card-container-header[withViewAll] {
+ width: 76%;
+ }
+ .view-all-link {
+ width: 20%;
+ }
+}
+
+.card-container.inner {
+ border: 1px solid var(--fxview-border);
+ box-shadow: none;
+ margin-block: 8px 0;
+}
+
+details.empty-state {
+ box-shadow: none;
+ border: 1px solid var(--fxview-border);
+ border-radius: 8px;
+}
diff --git a/browser/components/firefoxview/card-container.mjs b/browser/components/firefoxview/card-container.mjs
new file mode 100644
index 0000000000..b58f42204a
--- /dev/null
+++ b/browser/components/firefoxview/card-container.mjs
@@ -0,0 +1,208 @@
+/* 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 {
+ classMap,
+ html,
+ ifDefined,
+ when,
+} 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} isEmptyState - Optional property given if the card is used within an empty state
+ * @property {boolean} isInnerCard - Optional property given if the card a nested card within another card and given a border rather than box-shadow
+ * @property {boolean} preserveCollapseState - Whether or not the expanded/collapsed state should persist
+ * @property {string} shortPageName - Page name that the 'View all' link will navigate to and the preserveCollapseState pref will use
+ * @property {boolean} showViewAll - True if you need to display a 'View all' header link to navigate
+ * @property {boolean} toggleDisabled - Optional property given if the card container should not be collapsible
+ * @property {boolean} removeBlockEndMargin - True if you need to remove the block end margin on the card container
+ */
+class CardContainer extends MozLitElement {
+ constructor() {
+ super();
+ this.initiallyExpanded = true;
+ this.isExpanded = false;
+ this.visible = false;
+ }
+
+ static properties = {
+ sectionLabel: { type: String },
+ hideHeader: { type: Boolean },
+ isExpanded: { type: Boolean },
+ isEmptyState: { type: Boolean },
+ isInnerCard: { type: Boolean },
+ preserveCollapseState: { type: Boolean },
+ shortPageName: { type: String },
+ showViewAll: { type: Boolean },
+ toggleDisabled: { type: Boolean },
+ removeBlockEndMargin: { type: Boolean },
+ visible: { type: Boolean },
+ };
+
+ static queries = {
+ detailsEl: "details",
+ mainSlot: "slot[name=main]",
+ summaryEl: "summary",
+ viewAllLink: ".view-all-link",
+ };
+
+ get detailsExpanded() {
+ return this.detailsEl.hasAttribute("open");
+ }
+
+ get detailsOpenPrefValue() {
+ const prefName = this.shortPageName
+ ? `browser.tabs.firefox-view.ui-state.${this.shortPageName}.open`
+ : null;
+ if (prefName && Services.prefs.prefHasUserValue(prefName)) {
+ return Services.prefs.getBoolPref(prefName);
+ }
+ return null;
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.isExpanded = this.detailsOpenPrefValue ?? this.initiallyExpanded;
+ }
+
+ onToggleContainer() {
+ if (this.isExpanded == this.detailsExpanded) {
+ return;
+ }
+ this.isExpanded = this.detailsExpanded;
+
+ this.updateTabLists();
+
+ if (!this.shortPageName) {
+ return;
+ }
+
+ if (this.preserveCollapseState) {
+ const prefName = this.shortPageName
+ ? `browser.tabs.firefox-view.ui-state.${this.shortPageName}.open`
+ : null;
+ Services.prefs.setBoolPref(prefName, this.isExpanded);
+ }
+
+ // Record telemetry
+ Services.telemetry.recordEvent(
+ "firefoxview_next",
+ this.isExpanded ? "card_expanded" : "card_collapsed",
+ "card_container",
+ null,
+ {
+ data_type: this.shortPageName,
+ }
+ );
+ }
+
+ viewAllClicked() {
+ this.dispatchEvent(
+ new CustomEvent("card-container-view-all", {
+ bubbles: true,
+ composed: true,
+ })
+ );
+ }
+
+ willUpdate(changes) {
+ if (changes.has("visible")) {
+ this.updateTabLists();
+ }
+ }
+
+ updateTabLists() {
+ let tabLists = this.querySelectorAll("fxview-tab-list");
+ if (tabLists) {
+ tabLists.forEach(tabList => {
+ tabList.updatesPaused = !this.visible || !this.isExpanded;
+ });
+ }
+ }
+
+ render() {
+ return html`
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/firefoxview/card-container.css"
+ />
+ <section
+ aria-labelledby="header"
+ aria-label=${ifDefined(this.sectionLabel)}
+ >
+ ${when(
+ this.toggleDisabled,
+ () => html`<div
+ class=${classMap({
+ "card-container": true,
+ inner: this.isInnerCard,
+ "empty-state": this.isEmptyState && !this.isInnerCard,
+ })}
+ >
+ <span
+ id="header"
+ class="card-container-header"
+ ?hidden=${ifDefined(this.hideHeader)}
+ toggleDisabled
+ ?withViewAll=${this.showViewAll}
+ >
+ <slot name="header"></slot>
+ <slot name="secondary-header"></slot>
+ </span>
+ <a
+ href="about:firefoxview#${this.shortPageName}"
+ @click=${this.viewAllClicked}
+ class="view-all-link"
+ data-l10n-id="firefoxview-view-all-link"
+ ?hidden=${!this.showViewAll}
+ ></a>
+ <slot name="main"></slot>
+ <slot name="footer" class="card-container-footer"></slot>
+ </div>`,
+ () => html`<details
+ class=${classMap({
+ "card-container": true,
+ inner: this.isInnerCard,
+ "empty-state": this.isEmptyState && !this.isInnerCard,
+ })}
+ ?open=${this.isExpanded}
+ ?isOpenTabsView=${this.removeBlockEndMargin}
+ @toggle=${this.onToggleContainer}
+ >
+ <summary
+ id="header"
+ class="card-container-header"
+ ?hidden=${ifDefined(this.hideHeader)}
+ ?withViewAll=${this.showViewAll}
+ >
+ <span
+ class="icon chevron-icon"
+ aria-role="presentation"
+ data-l10n-id="firefoxview-collapse-button-${this.isExpanded
+ ? "hide"
+ : "show"}"
+ ></span>
+ <slot name="header"></slot>
+ </summary>
+ <a
+ href="about:firefoxview#${this.shortPageName}"
+ @click=${this.viewAllClicked}
+ class="view-all-link"
+ data-l10n-id="firefoxview-view-all-link"
+ ?hidden=${!this.showViewAll}
+ ></a>
+ <slot name="main"></slot>
+ <slot name="footer" class="card-container-footer"></slot>
+ </details>`
+ )}
+ </section>
+ `;
+ }
+}
+customElements.define("card-container", CardContainer);
diff --git a/browser/components/firefoxview/content/callout-tab-pickup-dark.svg b/browser/components/firefoxview/content/callout-tab-pickup-dark.svg
new file mode 100644
index 0000000000..b38684c38a
--- /dev/null
+++ b/browser/components/firefoxview/content/callout-tab-pickup-dark.svg
@@ -0,0 +1,4 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg width="350" height="152" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#a)"><path opacity=".07" d="M165.721 125.287c-30.865-16.041-92.958 7.521-101.635-16.041-6.348-17.372 26.486-23.769 9.488-60.003s55.914-54.573 80.964-38.917c25.05 15.656 77.755-4.574 109.145 13.313 14.543 8.334 25.627 24.337 17.613 35.431-3.318 4.586-9.475 7.678-11.451 12.827-3.85 10.047 11.864 29.166 11.451 39.971-1.946 50.785-84.71 29.461-115.575 13.419Z" fill="#FF6D33"/><path d="M60.315 95.195H163.23M46 90.723h146.666" stroke="#FBFBFE" stroke-width="1.123" stroke-linecap="round" stroke-linejoin="round"/><path d="M84.792 100.023c-1.945 2.248-.348 5.742 2.625 5.742h118.985c2.737 0 4.397-3.02 2.93-5.331l-4.554-7.176a3.47 3.47 0 0 0-2.93-1.611H93.628a3.47 3.47 0 0 0-2.625 1.2l-6.21 7.176Z" fill="#42414D" stroke="#FBFBFE" stroke-width="1.431"/><rect x="-.716" y=".716" width="39.237" height="63.159" rx="9.451" transform="matrix(-1 0 0 1 257.44 60.43)" fill="#42414D" stroke="#FBFBFE" stroke-width="1.431"/><rect x="91.533" y="16.29" width="113.994" height="78.111" rx="9.451" fill="#42414D" stroke="#FBFBFE" stroke-width="1.431"/><g clip-path="url(#b)"><rect x="97.395" y="21.559" width="101.67" height="66.983" rx="5.981" fill="#42414D"/><rect x="102.768" y="24.744" width="26.587" height="6.015" rx=".981" fill="#E0490E" stroke="#FBFBFE" stroke-width=".716"/><path stroke="#FBFBFE" stroke-width=".895" d="M93.863 34.811h106.461v8.946H93.863z"/><circle cx="104.711" cy="38.749" r="1.942" fill="#42414D" stroke="#FBFBFE" stroke-width=".895"/><circle cx="111.217" cy="38.749" r="1.942" fill="#42414D" stroke="#FBFBFE" stroke-width=".895"/><rect x="135.672" y="24.744" width="26.587" height="6.015" rx=".981" fill="#E0490E" stroke="#FBFBFE" stroke-width=".716"/></g><rect x="96.859" y="21.022" width="102.744" height="68.056" rx="6.517" stroke="#FBFBFE" stroke-width="1.074"/><g clip-path="url(#c)"><rect x="224.184" y="66.406" width="29.305" height="52.629" rx="5.981" fill="#fff" fill-opacity=".05"/><rect x="228.505" y="70.144" width="21.471" height="5.368" rx=".895" fill="#E0490E" stroke="#FBFBFE" stroke-width=".895"/><path stroke="#FBFBFE" stroke-width=".895" d="M220.006 79.541h101.093v4.473H220.006z"/></g><rect x="223.513" y="65.735" width="30.647" height="53.971" rx="6.652" stroke="#FBFBFE" stroke-width="1.342"/><circle cx="238.538" cy="113.658" r="3.438" fill="#42414D" stroke="#FBFBFE" stroke-width=".895"/><path fill-rule="evenodd" clip-rule="evenodd" d="m252.813 42.754-6.381.1c-.181-11.601-9.901-21.023-21.212-20.846-5.221.081-10.124 1.898-13.844 5.148-.571.589-.852 1.173-.843 1.753.009.58.013.87.312 1.446.884.856 2.339 1.124 3.195.24 3.15-2.66 6.897-4.169 10.958-4.232 9.28-.145 16.939 7.278 17.084 16.558l-6.381.1c-1.16.018-1.717 1.477-.834 2.334l8.252 8.284 2.61-.041 8.284-8.252c.852-1.174-.04-2.61-1.2-2.592Zm-75.031 81.36 6.381-.1c1.16-.018 1.718-1.477.834-2.333l-8.252-8.284-2.61.041-7.994 8.247c-.852 1.174.041 2.61 1.201 2.592l6.38-.1c.181 11.601 9.901 21.023 21.502 20.842 4.931-.077 9.834-1.894 13.554-5.143.857-.884 1.124-2.339.241-3.195-.884-.857-2.339-1.124-3.195-.24-3.15 2.66-6.897 4.169-10.958 4.232-9.28.145-16.939-7.278-17.084-16.559Z" fill="#E0490E"/><path d="M243.713 124.18h27.734M245.503 127.402h18.787" stroke="#FBFBFE" stroke-width="1.123" stroke-linecap="round" stroke-linejoin="round"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h350v152H0z"/></clipPath><clipPath id="b"><rect x="97.395" y="21.559" width="101.67" height="66.983" rx="5.981" fill="#fff"/></clipPath><clipPath id="c"><rect x="224.184" y="66.406" width="29.305" height="52.629" rx="5.981" fill="#fff"/></clipPath></defs></svg>
diff --git a/browser/components/firefoxview/content/callout-tab-pickup.svg b/browser/components/firefoxview/content/callout-tab-pickup.svg
new file mode 100644
index 0000000000..1ccc36dcc8
--- /dev/null
+++ b/browser/components/firefoxview/content/callout-tab-pickup.svg
@@ -0,0 +1,4 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 276.11 140.32"><defs><style>.cls-1{fill:#fff;}.cls-2{clip-path:url(#clippath-1);}.cls-3{opacity:.05;}.cls-4{isolation:isolate;opacity:.07;}.cls-5{fill:#fe7e4b;}.cls-6,.cls-7{fill:#ff7139;}.cls-8{clip-path:url(#clippath);}.cls-9{fill:none;}.cls-10{opacity:.1;}.cls-7{fill-rule:evenodd;}</style><clipPath id="clippath"><rect class="cls-9" x="51.96" y="16.85" width="101.67" height="66.98" rx="5.98" ry="5.98"/></clipPath><clipPath id="clippath-1"><rect class="cls-9" x="178.75" y="61.7" width="29.3" height="52.63" rx="5.98" ry="5.98"/></clipPath></defs><g class="cls-4"><path class="cls-6" d="M120.28,120.58c-30.86-16.04-92.96,7.52-101.63-16.04-6.35-17.37,26.49-23.77,9.49-60C11.14,8.31,84.05-10.03,109.1,5.62c25.05,15.66,77.76-4.57,109.15,13.31,14.54,8.33,25.63,24.34,17.61,35.43-3.32,4.59-9.47,7.68-11.45,12.83-3.85,10.05,11.86,29.17,11.45,39.97-1.95,50.79-84.71,29.46-115.57,13.42Z"/></g><path d="M117.79,91.05H14.88c-.31,0-.56-.25-.56-.56s.25-.56,.56-.56H117.79c.31,0,.56,.25,.56,.56s-.25,.56-.56,.56Z"/><path d="M147.23,86.58H.56c-.31,0-.56-.25-.56-.56s.25-.56,.56-.56H147.23c.31,0,.56,.25,.56,.56s-.25,.56-.56,.56Z"/><g><path class="cls-1" d="M39.35,95.32c-1.95,2.25-.35,5.74,2.62,5.74h118.99c2.74,0,4.4-3.02,2.93-5.33l-4.55-7.18c-.64-1-1.74-1.61-2.93-1.61H48.19c-1.01,0-1.97,.44-2.62,1.2l-6.21,7.18Z"/><path d="M160.96,101.78H41.98c-1.66,0-3.12-.94-3.81-2.45s-.44-3.22,.64-4.48l6.21-7.18c.8-.92,1.95-1.45,3.17-1.45h108.22c1.44,0,2.76,.73,3.53,1.94l4.55,7.18c.83,1.31,.88,2.9,.13,4.26-.75,1.36-2.12,2.17-3.67,2.17Zm-112.77-14.12c-.8,0-1.56,.35-2.08,.95l-6.21,7.18c-.71,.83-.88,1.95-.42,2.95,.45,.99,1.41,1.61,2.51,1.61h118.99c1.02,0,1.92-.53,2.41-1.43,.49-.89,.46-1.94-.09-2.8l-4.55-7.18c-.51-.8-1.38-1.28-2.33-1.28H48.19Z"/></g><g><rect class="cls-1" x="173.48" y="56.44" width="39.24" height="63.16" rx="9.45" ry="9.45"/><path d="M203.27,120.32h-20.33c-5.61,0-10.17-4.56-10.17-10.17v-44.26c0-5.61,4.56-10.17,10.17-10.17h20.33c5.61,0,10.17,4.56,10.17,10.17v44.26c0,5.61-4.56,10.17-10.17,10.17Zm-20.33-63.16c-4.82,0-8.74,3.92-8.74,8.74v44.26c0,4.82,3.92,8.74,8.74,8.74h20.33c4.82,0,8.74-3.92,8.74-8.74v-44.26c0-4.82-3.92-8.74-8.74-8.74h-20.33Z"/></g><g><rect class="cls-1" x="46.09" y="11.59" width="113.99" height="78.11" rx="9.45" ry="9.45"/><path d="M150.64,90.41H55.55c-5.61,0-10.17-4.56-10.17-10.17V21.04c0-5.61,4.56-10.17,10.17-10.17h95.09c5.61,0,10.17,4.56,10.17,10.17v59.21c0,5.61-4.56,10.17-10.17,10.17ZM55.55,12.3c-4.82,0-8.74,3.92-8.74,8.74v59.21c0,4.82,3.92,8.74,8.74,8.74h95.09c4.82,0,8.74-3.92,8.74-8.74V21.04c0-4.82-3.92-8.74-8.74-8.74H55.55Z"/></g><g class="cls-8"><g><g class="cls-10"><rect class="cls-1" x="51.96" y="16.85" width="101.67" height="66.98" rx="5.98" ry="5.98"/></g><g><rect class="cls-6" x="57.33" y="20.04" width="26.59" height="6.01" rx=".98" ry=".98"/><path d="M82.94,26.41h-24.62c-.74,0-1.34-.6-1.34-1.34v-4.05c0-.74,.6-1.34,1.34-1.34h24.62c.74,0,1.34,.6,1.34,1.34v4.05c0,.74-.6,1.34-1.34,1.34Zm-24.62-6.01c-.34,0-.62,.28-.62,.62v4.05c0,.34,.28,.62,.62,.62h24.62c.34,0,.62-.28,.62-.62v-4.05c0-.34-.28-.62-.62-.62h-24.62Z"/></g><path d="M155.33,39.5H47.98v-9.84h107.36v9.84Zm-106.46-.89h105.57v-8.05H48.87v8.05Z"/><g><circle class="cls-1" cx="59.27" cy="34.04" r="1.94"/><path d="M59.27,36.43c-1.32,0-2.39-1.07-2.39-2.39s1.07-2.39,2.39-2.39,2.39,1.07,2.39,2.39-1.07,2.39-2.39,2.39Zm0-3.89c-.82,0-1.5,.67-1.5,1.5s.67,1.5,1.5,1.5,1.5-.67,1.5-1.5-.67-1.5-1.5-1.5Z"/></g><g><circle class="cls-1" cx="65.78" cy="34.04" r="1.94"/><path d="M65.78,36.43c-1.32,0-2.39-1.07-2.39-2.39s1.07-2.39,2.39-2.39,2.39,1.07,2.39,2.39-1.07,2.39-2.39,2.39Zm0-3.89c-.82,0-1.5,.67-1.5,1.5s.67,1.5,1.5,1.5,1.5-.67,1.5-1.5-.67-1.5-1.5-1.5Z"/></g><g><rect class="cls-5" x="90.23" y="20.04" width="26.59" height="6.01" rx=".98" ry=".98"/><path d="M115.84,26.41h-24.62c-.74,0-1.34-.6-1.34-1.34v-4.05c0-.74,.6-1.34,1.34-1.34h24.62c.74,0,1.34,.6,1.34,1.34v4.05c0,.74-.6,1.34-1.34,1.34Zm-24.62-6.01c-.34,0-.62,.28-.62,.62v4.05c0,.34,.28,.62,.62,.62h24.62c.34,0,.62-.28,.62-.62v-4.05c0-.34-.28-.62-.62-.62h-24.62Z"/></g></g></g><path d="M147.65,84.91H57.94c-3.89,0-7.05-3.17-7.05-7.05V22.83c0-3.89,3.16-7.05,7.05-7.05h89.71c3.89,0,7.05,3.16,7.05,7.05v55.02c0,3.89-3.17,7.05-7.05,7.05ZM57.94,16.85c-3.3,0-5.98,2.68-5.98,5.98v55.02c0,3.3,2.68,5.98,5.98,5.98h89.71c3.3,0,5.98-2.68,5.98-5.98V22.83c0-3.3-2.68-5.98-5.98-5.98H57.94Z"/><g class="cls-2"><g><g class="cls-3"><rect class="cls-1" x="178.75" y="61.7" width="29.3" height="52.63" rx="5.98" ry="5.98"/></g><g><rect class="cls-6" x="183.07" y="65.44" width="21.47" height="5.37" rx=".89" ry=".89"/><path d="M203.64,71.26h-19.68c-.74,0-1.34-.6-1.34-1.34v-3.58c0-.74,.6-1.34,1.34-1.34h19.68c.74,0,1.34,.6,1.34,1.34v3.58c0,.74-.6,1.34-1.34,1.34Zm-19.68-5.37c-.25,0-.45,.2-.45,.45v3.58c0,.25,.2,.45,.45,.45h19.68c.25,0,.45-.2,.45-.45v-3.58c0-.25-.2-.45-.45-.45h-19.68Z"/></g><path d="M276.11,79.76h-101.99v-5.37h101.99v5.37Zm-101.09-.89h100.2v-3.58h-100.2v3.58Z"/></g></g><path d="M202.07,115.68h-17.34c-4.04,0-7.32-3.29-7.32-7.32v-40.67c0-4.04,3.29-7.32,7.32-7.32h17.34c4.04,0,7.32,3.28,7.32,7.32v40.67c0,4.04-3.29,7.32-7.32,7.32Zm-17.34-53.97c-3.3,0-5.98,2.68-5.98,5.98v40.67c0,3.3,2.68,5.98,5.98,5.98h17.34c3.3,0,5.98-2.68,5.98-5.98v-40.67c0-3.3-2.68-5.98-5.98-5.98h-17.34Z"/><g><circle class="cls-1" cx="193.1" cy="108.95" r="3.44"/><path d="M193.1,112.84c-2.14,0-3.88-1.74-3.88-3.88s1.74-3.88,3.88-3.88,3.88,1.74,3.88,3.88-1.74,3.88-3.88,3.88Zm0-6.88c-1.65,0-2.99,1.34-2.99,2.99s1.34,2.99,2.99,2.99,2.99-1.34,2.99-2.99-1.34-2.99-2.99-2.99Z"/></g><path class="cls-7" d="M207.37,38.05l-6.38,.1c-.18-11.6-9.9-21.02-21.21-20.85-5.22,.08-10.12,1.9-13.84,5.15-.57,.59-.85,1.17-.84,1.75,0,.58,.01,.87,.31,1.45,.88,.86,2.34,1.12,3.19,.24,3.15-2.66,6.9-4.17,10.96-4.23,9.28-.14,16.94,7.28,17.08,16.56l-6.38,.1c-1.16,.02-1.72,1.48-.83,2.33l8.25,8.28,2.61-.04,8.28-8.25c.85-1.17-.04-2.61-1.2-2.59Zm-75.03,81.36l6.38-.1c1.16-.02,1.72-1.48,.83-2.33l-8.25-8.28-2.61,.04-7.99,8.25c-.85,1.17,.04,2.61,1.2,2.59l6.38-.1c.18,11.6,9.9,21.02,21.5,20.84,4.93-.08,9.83-1.89,13.55-5.14,.86-.88,1.12-2.34,.24-3.2-.88-.86-2.34-1.12-3.19-.24-3.15,2.66-6.9,4.17-10.96,4.23-9.28,.15-16.94-7.28-17.08-16.56Z"/><path d="M226.01,120.04h-27.73c-.31,0-.56-.25-.56-.56s.25-.56,.56-.56h27.73c.31,0,.56,.25,.56,.56s-.25,.56-.56,.56Z"/><path d="M218.85,123.26h-18.79c-.31,0-.56-.25-.56-.56s.25-.56,.56-.56h18.79c.31,0,.56,.25,.56,.56s-.25,.56-.56,.56Z"/></svg>
diff --git a/browser/components/firefoxview/content/category-history.svg b/browser/components/firefoxview/content/category-history.svg
new file mode 100644
index 0000000000..a6dc259483
--- /dev/null
+++ b/browser/components/firefoxview/content/category-history.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M10 10h-.75a.75.75 0 0 0 .375.65L10 10Zm0 8a8 8 0 0 0 8-8h-1.5a6.5 6.5 0 0 1-6.5 6.5V18Zm8-8a8 8 0 0 0-8-8v1.5a6.5 6.5 0 0 1 6.5 6.5H18Zm-8-8a8 8 0 0 0-8 8h1.5A6.5 6.5 0 0 1 10 3.5V2Zm-8 8a8 8 0 0 0 8 8v-1.5A6.5 6.5 0 0 1 3.5 10H2Zm7.25-4v4h1.5V6h-1.5Zm.375 4.65 3.464 2 .75-1.3-3.464-2-.75 1.3Z"/>
+</svg>
diff --git a/browser/components/firefoxview/content/category-opentabs.svg b/browser/components/firefoxview/content/category-opentabs.svg
new file mode 100644
index 0000000000..2172558a42
--- /dev/null
+++ b/browser/components/firefoxview/content/category-opentabs.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M4 4a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H4Zm-.5 2a.5.5 0 0 1 .5-.5h3.5v2A.5.5 0 0 0 8 8h8.5v6a.5.5 0 0 1-.5.5H4a.5.5 0 0 1-.5-.5V6Z"/>
+</svg>
diff --git a/browser/components/firefoxview/content/category-recentbrowsing.svg b/browser/components/firefoxview/content/category-recentbrowsing.svg
new file mode 100644
index 0000000000..f4c523dafa
--- /dev/null
+++ b/browser/components/firefoxview/content/category-recentbrowsing.svg
@@ -0,0 +1,7 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path fill-rule="evenodd" d="m4.444 4.151.182-.849c.086-.402.66-.402.747 0l.182.849a.381.381 0 0 0 .293.293l.849.182c.402.086.402.66 0 .747l-.848.183a.38.38 0 0 0-.293.293l-.183.849c-.086.402-.66.402-.747 0l-.182-.849a.38.38 0 0 0-.293-.293l-.849-.183c-.402-.086-.402-.66 0-.747l.849-.182a.38.38 0 0 0 .293-.293ZM4.444 14.151l.182-.849c.086-.402.66-.402.747 0l.182.849a.381.381 0 0 0 .293.293l.849.182c.402.086.402.66 0 .747l-.849.182a.381.381 0 0 0-.293.293l-.182.849c-.086.402-.66.402-.747 0l-.182-.849a.381.381 0 0 0-.293-.293l-.849-.182c-.402-.086-.402-.66 0-.747l.849-.182a.38.38 0 0 0 .293-.293Z"/>
+ <path fill-rule="evenodd" d="M13.173 4.799c-.42-1.064-1.927-1.064-2.347 0L9.771 7.475a.528.528 0 0 1-.297.297L6.8 8.827c-1.064.42-1.064 1.926 0 2.346l2.675 1.055a.529.529 0 0 1 .297.298l1.055 2.676c.42 1.065 1.927 1.064 2.347 0l1.055-2.676a.528.528 0 0 1 .298-.297l2.675-1.055c1.064-.42 1.064-1.927 0-2.347l-2.676-1.055a.528.528 0 0 1-.298-.297l-1.054-2.676Zm-2.006 3.226.832-2.112.833 2.113c.206.52.619.935 1.143 1.142l2.112.833-2.113.833a2.027 2.027 0 0 0-1.142 1.142L12 14.088l-.833-2.113a2.029 2.029 0 0 0-1.142-1.143L7.913 10l2.113-.833c.521-.206.934-.62 1.14-1.142Z"/>
+</svg>
diff --git a/browser/components/firefoxview/content/category-recentlyclosed.svg b/browser/components/firefoxview/content/category-recentlyclosed.svg
new file mode 100644
index 0000000000..7cac65ac58
--- /dev/null
+++ b/browser/components/firefoxview/content/category-recentlyclosed.svg
@@ -0,0 +1,7 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M13.5 15.941v-1.5H4a.501.501 0 0 1-.5-.5v-8c0-.275.225-.5.5-.5h4.5v2a.5.5 0 0 0 .5.5h7.5v3.5H18v-5.5a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h9.5Z"/>
+ <path d="m20 14.001-1.44 1.44 1.44 1.44-1.06 1.06-1.44-1.44-1.44 1.44-1.06-1.06 1.44-1.44-1.44-1.44 1.06-1.06 1.44 1.44 1.44-1.44L20 14Z"/>
+</svg>
diff --git a/browser/components/firefoxview/content/category-syncedtabs.svg b/browser/components/firefoxview/content/category-syncedtabs.svg
new file mode 100644
index 0000000000..bd9749743c
--- /dev/null
+++ b/browser/components/firefoxview/content/category-syncedtabs.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M3.2 4.4c0-.22.18-.4.4-.4h5.2v4H3.6a.4.4 0 0 1-.4-.4V4.4Zm.4-1.6A1.6 1.6 0 0 0 2 4.4v3.2a1.6 1.6 0 0 0 1.6 1.6h5.6a1.6 1.6 0 0 0 1.6-1.6V4.4a1.6 1.6 0 0 0-1.6-1.6H3.6Zm12 3.6H12V5.2h3.6a1.6 1.6 0 0 1 1.6 1.6v6.4a1.6 1.6 0 0 1-1.6 1.6H18V16H3.6v-1.2H6a1.6 1.6 0 0 1-1.6-1.6v-2.8h1.2v2.8c0 .22.18.4.4.4h9.6a.4.4 0 0 0 .4-.4V6.8a.4.4 0 0 0-.4-.4Z"/>
+</svg>
diff --git a/browser/components/firefoxview/content/history-empty.svg b/browser/components/firefoxview/content/history-empty.svg
new file mode 100644
index 0000000000..4fb4d5021c
--- /dev/null
+++ b/browser/components/firefoxview/content/history-empty.svg
@@ -0,0 +1,20 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="100" height="83" fill="none">
+ <path fill="url(#a)" d="M4.934 61.257c1.254 6.3 4.59 11.747 22.776 6.043 18.188-5.705 13.783-2.664 39.263 4.792 25.48 7.455 44.035-23.568 25.295-38.985C73.528 17.69 77.492 3.367 65.775.701c-11.717-2.667-13.2 10.272-30.728 9.286C17.52 9-2.85 15.505 1.041 24.306c3.893 8.801 8.575 15.467 8.728 20.521.077 2.591-6.089 10.13-4.835 16.43Z" opacity=".12"/>
+ <path fill="#AB71FF" stroke="context-stroke" stroke-width=".917" d="M33.353 9.009a3.21 3.21 0 0 1 4.107-1.901l38.505 14.014a3.21 3.21 0 0 1 1.918 4.114L62.182 68.375c-.123.337-.314.61-.508.768-.198.162-.315.145-.345.134L17.476 53.316c-.03-.011-.13-.073-.178-.325-.047-.245-.018-.577.104-.913v-.002L33.353 9.01Z"/>
+ <path fill="context-fill" stroke="context-stroke" stroke-width=".917" d="M17.817 50.94v-.001l14.15-38.068 44.52 16.204-13.89 38.162a1.167 1.167 0 0 1-.41.562c-.16.11-.28.107-.35.083L17.986 51.92c-.068-.025-.162-.102-.215-.288a1.166 1.166 0 0 1 .047-.693Z"/>
+ <rect width="12.313" height="2.703" x="37.124" y="9.731" fill="context-fill" stroke="context-stroke" stroke-width=".5" rx=".25" transform="rotate(20 37.124 9.73)"/>
+ <rect width="12.313" height="2.703" x="51.427" y="15.03" fill="context-fill" stroke="context-stroke" stroke-width=".5" rx=".25" transform="rotate(20 51.427 15.03)"/>
+ <path fill="context-fill" stroke="context-stroke" d="M46.909 34.089h29.024a.5.5 0 0 1 .5.5v42.457a.5.5 0 0 1-.5.5H17.217a.5.5 0 0 1-.5-.5V27.569a.5.5 0 0 1 .5-.5H36.9a.5.5 0 0 1 .3.1l8.808 6.619c.26.195.576.3.9.3Z"/>
+ <path stroke="context-stroke" stroke-linecap="round" d="M74.793 77.546h22.759M69.514 81.506h17.479"/>
+ <path fill="context-fill" stroke="context-stroke" d="M87.083 64.212c0 7.02-5.691 12.711-12.712 12.711-7.02 0-12.711-5.69-12.711-12.712 0-7.02 5.69-12.711 12.712-12.711 7.02 0 12.711 5.691 12.711 12.712Z"/>
+ <path stroke="context-stroke" stroke-linecap="round" stroke-linejoin="round" stroke-width=".75" d="M72.72 57.605v8.258h8.258"/>
+ <defs>
+ <linearGradient id="a" x1="97.571" x2="2.704" y1="33.156" y2="48.688" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#7542E4"/>
+ <stop offset="1" stop-color="#FF9AA2"/>
+ </linearGradient>
+ </defs>
+</svg>
diff --git a/browser/components/firefoxview/content/recentlyclosed-empty.svg b/browser/components/firefoxview/content/recentlyclosed-empty.svg
new file mode 100644
index 0000000000..e8bd265df0
--- /dev/null
+++ b/browser/components/firefoxview/content/recentlyclosed-empty.svg
@@ -0,0 +1,25 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg width="100" height="87" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path opacity=".25" d="M60.978 10.288C70.863 20.61 90.238.811 97.805 20.646c7.566 19.835-15.526 11.738-10.466 37.806 5.06 26.068-33.743 26.37-41.319 16.52-7.575-9.849-32.019 1.49-41.805-9.884-4.526-5.296-5.858-15.106-.7-20.947 2.135-2.413 8.292-7.808 9.973-10.596 3.28-5.44-4.86-13.746 2.523-25.769 7.382-12.022 35.081-7.81 44.967 2.512Z" fill="url(#a)"/>
+ <path stroke="context-stroke" stroke-linecap="round" d="M7.523 81.816h57.238M25.914 86.414h34.249"/>
+ <path fill="context-fill" d="M29.232 16.149 81.58 35.2 65.162 80.307 12.815 61.254z"/>
+ <path d="M31.4 10.198a3 3 0 0 1 3.845-1.793L81.747 25.33a3 3 0 0 1 1.793 3.845L81.12 35.82l-52.14-18.977 2.419-6.644Z" fill="#CB9EFF"/>
+ <rect x=".148" y=".32" width="13.886" height="3.5" rx=".25" transform="matrix(.9397 .34202 -.34854 .9373 34.265 11.037)" fill="context-fill" stroke="context-stroke" stroke-width=".5"/>
+ <rect x=".148" y=".32" width="13.886" height="3.5" rx=".25" transform="matrix(.9397 .34202 -.34854 .9373 50.525 16.955)" fill="context-fill" stroke="context-stroke" stroke-width=".5"/>
+ <rect x=".148" y=".32" width="13.886" height="3.5" rx=".25" transform="matrix(.9397 .34202 -.34854 .9373 66.745 22.86)" fill="context-fill" stroke="context-stroke" stroke-width=".5"/>
+ <path stroke="context-stroke" d="m29.169 16.325 52.007 18.929M31 10.597a3.5 3.5 0 0 1 4.486-2.092L81.31 25.183a3.5 3.5 0 0 1 2.092 4.486L65.21 79.644a.5.5 0 0 1-.64.3L13.108 61.212a.5.5 0 0 1-.298-.64L31 10.596Z"/>
+ <mask id="b" maskUnits="userSpaceOnUse" x="22.931" y="19.539" width="23.755" height="24.95" fill="context-stroke">
+ <path fill="context-fill" d="M22.931 19.539h23.755v24.95H22.931z"/>
+ <path d="M43.01 31.776c-.022-.968-.36-2.127-.782-2.608.103 1.05.025 2.038-.124 2.741 0 0 0 .005-.003.014-.03-2.598-1.158-4.052-1.622-6.493a11.144 11.144 0 0 1-.067-.375 2.68 2.68 0 0 1-.023-.199 1.635 1.635 0 0 1 .004-.378.02.02 0 0 0-.01-.012.025.025 0 0 0-.014-.005h-.005l-.007.002.005-.003c-2.08.438-3.456 2.03-4.065 3.142a4.12 4.12 0 0 0-1.66-.14.216.216 0 0 0-.135.074.233.233 0 0 0-.041.235c.01.027.027.052.047.072a.193.193 0 0 0 .155.054c.49-.056.98-.018 1.45.114l.045.013a3.855 3.855 0 0 1 1.227.62l.054.044a3.839 3.839 0 0 1 .261.225l.085.082c.044.044.086.089.128.135l.056.063a3.868 3.868 0 0 1 .189.235c.42.565.695 1.236.8 1.952-.297-.452-.917-1.018-1.667-1.143 2.213 2.456-.35 7.208-4.083 5.635a3.418 3.418 0 0 1-.918-.565 4.42 4.42 0 0 1-.294-.29c-.745-.852-1.162-2.098-.83-3.308 0 0 .86-1.266 3.028-.477.234.086 1.158-.366 1.243-.563.022-.067-1.101-1.111-1.422-1.841-.17-.39-.251-.578-.33-.725a2.432 2.432 0 0 0-.14-.23 4.24 4.24 0 0 1 .7-1.99c-.921.094-1.749.472-2.384.84l-.004-.001c-.154-.518.356-1.853.476-2.127.002-.018-.27.04-.307.051-.34.105-.673.242-.993.408a7.516 7.516 0 0 0-2.96 2.732c-.005.008-.196.313-.396.702l-.094.182a6.113 6.113 0 0 0-.214.448l-.01.023-.114.264-.015.041c-1.63 4.477.464 9.35 4.676 10.882 3.772 1.373 7.964-.398 9.968-3.998.051-.099.1-.2.149-.3.657-1.335 1.02-2.858.988-4.254Z"/>
+ </mask>
+ <path d="M43.01 31.776c-.022-.968-.36-2.127-.782-2.608.103 1.05.025 2.038-.124 2.741 0 0 0 .005-.003.014-.03-2.598-1.158-4.052-1.622-6.493a11.144 11.144 0 0 1-.067-.375 2.68 2.68 0 0 1-.023-.199 1.635 1.635 0 0 1 .004-.378.02.02 0 0 0-.01-.012.025.025 0 0 0-.014-.005h-.005l-.007.002.005-.003c-2.08.438-3.456 2.03-4.065 3.142a4.12 4.12 0 0 0-1.66-.14.216.216 0 0 0-.135.074.233.233 0 0 0-.041.235c.01.027.027.052.047.072a.193.193 0 0 0 .155.054c.49-.056.98-.018 1.45.114l.045.013a3.855 3.855 0 0 1 1.227.62l.054.044a3.839 3.839 0 0 1 .261.225l.085.082c.044.044.086.089.128.135l.056.063a3.868 3.868 0 0 1 .189.235c.42.565.695 1.236.8 1.952-.297-.452-.917-1.018-1.667-1.143 2.213 2.456-.35 7.208-4.083 5.635a3.418 3.418 0 0 1-.918-.565 4.42 4.42 0 0 1-.294-.29c-.745-.852-1.162-2.098-.83-3.308 0 0 .86-1.266 3.028-.477.234.086 1.158-.366 1.243-.563.022-.067-1.101-1.111-1.422-1.841-.17-.39-.251-.578-.33-.725a2.432 2.432 0 0 0-.14-.23 4.24 4.24 0 0 1 .7-1.99c-.921.094-1.749.472-2.384.84l-.004-.001c-.154-.518.356-1.853.476-2.127.002-.018-.27.04-.307.051-.34.105-.673.242-.993.408a7.516 7.516 0 0 0-2.96 2.732c-.005.008-.196.313-.396.702l-.094.182a6.113 6.113 0 0 0-.214.448l-.01.023-.114.264-.015.041c-1.63 4.477.464 9.35 4.676 10.882 3.772 1.373 7.964-.398 9.968-3.998.051-.099.1-.2.149-.3.657-1.335 1.02-2.858.988-4.254Z" fill="#CB9EFF"/>
+ <path d="m43.01 31.776.75-.017-.75.017Zm-.782-2.608.563-.495-1.54-1.755.23 2.324.746-.074Zm-.124 2.741.728.183.003-.014.003-.014-.734-.155Zm-.003.014-.75.01 1.48.166-.73-.176Zm-1.622-6.493-.737.14.737-.14Zm-.067-.375-.74.113v.001l.74-.114Zm-.023-.199.748-.058v-.01l-.002-.01-.745.078Zm.003-.362-.695-.282-.038.094-.011.1.744.088Zm-.01-.028-.454.596.05.039.057.03.348-.665Zm-.013-.005.034-.75-.084-.003-.083.015.133.738Zm-.005 0 .128.74h.004l-.132-.74Zm-.007.002-.431-.613.56 1.352-.129-.739Zm.005-.003.432.613-.587-1.347.155.734Zm-4.065 3.142-.213.719.58.172.291-.53-.658-.361Zm-1.66-.14-.089-.745-.012.001.1.744Zm-.19.222.75.026v-.015l-.75-.011Zm.216.213.075.747.012-.002-.087-.745Zm1.45.114-.204.722.204-.722Zm.045.013.21-.72-.007-.002-.008-.002-.195.724Zm.191.062.252-.707-.003-.001-.249.708Zm1.036.56.455-.597-.002-.002-.453.598Zm.054.042-.464.59.002.001.462-.59Zm.154.127.497-.562-.006-.005-.492.566Zm.107.098-.518.543.004.004.514-.547Zm.085.082.533-.528-.003-.002-.53.53Zm.128.135-.557.502.001.002.556-.504Zm.056.063.568-.49-.002-.002-.566.492Zm.117.141-.59.464.002.002.588-.466Zm.072.094-.604.446.002.002.602-.448Zm.8 1.952-.628.412 1.37-.52-.742.108Zm-1.667-1.143.125-.74-2.12-.356 1.438 1.598.557-.503Zm-4.083 5.635.291-.691-.004-.002-.287.693Zm-.918-.565-.5.56.006.005.006.004.488-.569Zm-.189-.179-.538.523.006.006.532-.529Zm-.105-.112-.564.494.564-.494Zm-.83-3.307-.621-.42-.07.102-.033.12.724.198Zm4.271-1.04.688.3.016-.037.012-.037-.715-.226Zm-1.422-1.841.687-.302-.687.302Zm-.33-.725.663-.352v-.002l-.662.354Zm-.14-.23-.747-.065-.023.267.152.221.619-.424Zm.7-1.99.624.417.887-1.325-1.587.162.077.746Zm-2.384.84-.257.705.33.12.303-.176-.376-.65Zm-.004-.001-.718.214.107.361.355.13.256-.705Zm.476-2.127.687.3.04-.093.015-.099-.742-.108Zm-.307.051-.206-.721-.008.002-.008.003.222.716Zm-.993.408.342.667.003-.002-.345-.665Zm-1.043.648.446.604.002-.001-.447-.603Zm-1.917 2.084-.637-.396-.001.003.638.393Zm-.396.702.663.35.004-.007-.667-.343Zm-.094.182.661.354.005-.008.004-.01-.67-.336Zm-.214.448.684.308.003-.006-.687-.302Zm-.01.023-.686-.303-.001.003.687.3Zm-.114.264-.69-.291-.008.017-.006.018.704.256Zm14.63 6.925.655.365.006-.012.006-.011-.668-.342Zm.148-.3.673.331-.673-.331Zm1.737-4.271a6.63 6.63 0 0 0-.272-1.683c-.152-.51-.38-1.041-.697-1.403l-1.127.99c.105.12.257.407.386.84.123.414.201.88.211 1.291l1.5-.035Zm-2.279-2.517c.096.974.022 1.884-.11 2.512l1.467.31c.165-.78.247-1.844.136-2.97l-1.493.148Zm.623 2.667-.727-.182v.003l-.002.004-.003.014 1.458.35.002-.006v-.001l-.728-.182Zm.747.006c-.017-1.417-.335-2.518-.69-3.543-.358-1.035-.726-1.931-.945-3.082l-1.474.28c.246 1.29.673 2.342 1.002 3.293.332.962.593 1.887.607 3.07l1.5-.018Zm-1.635-6.624c-.024-.126-.045-.237-.062-.351l-1.483.229c.022.143.048.28.071.4l1.474-.278Zm-.062-.35a1.93 1.93 0 0 1-.017-.143l-1.495.117c.006.085.016.17.03.253l1.482-.227Zm-.019-.162a.883.883 0 0 1 .002-.196l-1.49-.177c-.02.175-.022.352-.003.528l1.491-.155Zm-.048-.003a.773.773 0 0 0 .017-.537l-1.422.478a.727.727 0 0 1 .015-.505l1.39.564Zm.017-.537a.77.77 0 0 0-.373-.437l-.696 1.329a.73.73 0 0 1-.353-.414l1.422-.478Zm-.266-.369a.775.775 0 0 0-.435-.158l-.07 1.498a.725.725 0 0 1-.405-.148l.91-1.192Zm-.602-.147h-.004l.264 1.477h.005l-.264-1.477Zm0 0-.007.001.257 1.478.006-.001-.256-1.478Zm.553 1.353.005-.003-.863-1.227-.005.004.863 1.226Zm-.582-1.35c-2.37.5-3.898 2.294-4.567 3.515l1.315.721c.548-1 1.772-2.391 3.562-2.768l-.31-1.468Zm-3.696 3.157a4.873 4.873 0 0 0-1.963-.166l.177 1.49a3.371 3.371 0 0 1 1.359.114l.427-1.438Zm-1.975-.165a.966.966 0 0 0-.604.33l1.14.976a.534.534 0 0 1-.334.18l-.202-1.486Zm-.604.33a.984.984 0 0 0-.235.624l1.5.023a.517.517 0 0 1-.125.329l-1.14-.975Zm-.235.61a.968.968 0 0 0 .065.383l1.398-.541a.53.53 0 0 1 .036.21l-1.499-.052Zm.065.383a.953.953 0 0 0 .22.336l1.052-1.069c.058.057.1.123.127.192l-1.4.541Zm.22.336a.941.941 0 0 0 .348.218l.499-1.415a.556.556 0 0 1 .205.128l-1.052 1.069Zm.348.218a.944.944 0 0 0 .408.049l-.15-1.493a.556.556 0 0 1 .24.03l-.498 1.414Zm.42.047a2.989 2.989 0 0 1 1.159.091l.407-1.444a4.491 4.491 0 0 0-1.74-.137l.174 1.49Zm1.16.091.053.015.39-1.448-.037-.01-.407 1.443Zm.038.011c.051.015.102.031.152.05l.498-1.416a4.49 4.49 0 0 0-.23-.074l-.42 1.44Zm.15.048c.299.106.58.258.834.451l.906-1.196a4.604 4.604 0 0 0-1.238-.668l-.502 1.413Zm.832.45s.002 0 .01.008l.035.027.929-1.178-.02-.015a1.671 1.671 0 0 0-.044-.035l-.91 1.192Zm.047.037c.043.033.084.067.124.102l.983-1.133a4.685 4.685 0 0 0-.183-.15l-.924 1.181Zm.118.097c.03.026.059.053.087.08l1.036-1.085a4.569 4.569 0 0 0-.128-.118l-.995 1.123Zm.091.084.07.066 1.06-1.061c-.038-.038-.075-.072-.102-.098l-1.028 1.093Zm.067.063c.035.036.07.072.103.11l1.113-1.006c-.049-.054-.1-.108-.151-.16l-1.065 1.056Zm.104.11.046.052 1.132-.984c-.024-.029-.05-.056-.067-.075l-1.11 1.008Zm.045.05.095.115 1.178-.928a4.738 4.738 0 0 0-.139-.168l-1.134.981Zm.096.116.056.075 1.207-.891a4.37 4.37 0 0 0-.087-.114l-1.176.93Zm.058.077c.344.462.572 1.015.66 1.613l1.484-.217a5.057 5.057 0 0 0-.94-2.292l-1.204.896Zm2.029 1.093c-.37-.565-1.15-1.301-2.17-1.472l-.248 1.48c.48.08.94.475 1.163.815l1.255-.823Zm-2.85-.23c.827.918.808 2.335.112 3.422-.665 1.04-1.87 1.643-3.348 1.02l-.582 1.382c2.254.95 4.198-.037 5.194-1.594.966-1.51 1.123-3.697-.262-5.235l-1.115 1.004Zm-3.24 4.44c-.26-.108-.501-.256-.717-.441l-.976 1.138c.335.288.712.52 1.12.689l.573-1.386Zm-.706-.431a3.624 3.624 0 0 1-.156-.148l-1.064 1.057c.072.072.145.142.221.21l1-1.12Zm-.15-.142a2.394 2.394 0 0 1-.079-.084l-1.128.989c.048.054.095.103.131.14l1.076-1.045Zm-.078-.084c-.612-.698-.925-1.692-.672-2.614l-1.447-.397c-.41 1.496.111 2.996.99 4l1.129-.989Zm-1.395-2.813a51.876 51.876 0 0 1 .619.423l-.001.002-.002.003a.098.098 0 0 1-.003.004l-.004.005.006-.008a1.21 1.21 0 0 1 .392-.28c.293-.13.846-.255 1.764.08l.513-1.41c-1.25-.456-2.21-.342-2.89-.037a2.71 2.71 0 0 0-.737.483 2.24 2.24 0 0 0-.265.296l-.008.01c0 .002-.002.003-.003.004v.002l-.001.001s-.001.001.62.422Zm2.771.228c.184.067.355.059.445.05a1.73 1.73 0 0 0 .293-.056c.173-.048.353-.12.514-.195.162-.076.328-.166.47-.261.069-.047.146-.104.216-.168.055-.05.175-.166.25-.338l-1.375-.6a.578.578 0 0 1 .081-.135c.017-.02.03-.032.032-.034.002-.002-.009.008-.041.03-.064.042-.16.096-.269.147-.11.052-.209.09-.278.108-.035.01-.045.01-.035.01a.563.563 0 0 1 .21.034l-.513 1.408Zm2.216-1.042c.082-.26-.005-.473-.016-.503a.834.834 0 0 0-.071-.142 1.203 1.203 0 0 0-.086-.12 4.404 4.404 0 0 0-.154-.177c-.118-.13-.253-.275-.41-.452-.325-.37-.607-.732-.714-.975l-1.374.603c.214.487.653 1.012.964 1.364.162.184.323.358.42.466.055.06.085.094.1.112.009.012-.003-.002-.02-.03a.657.657 0 0 1-.056-.113c-.009-.022-.095-.229-.014-.485l1.43.452Zm-1.45-2.369c-.164-.372-.26-.596-.355-.775l-1.325.703c.061.116.126.266.305.675l1.374-.603Zm-.356-.776a3.177 3.177 0 0 0-.183-.302l-1.237.848c.035.051.067.105.097.16l1.323-.706Zm-.054.187c.05-.584.25-1.15.576-1.637l-1.247-.834a4.991 4.991 0 0 0-.824 2.342l1.495.129Zm-.124-2.8c-1.07.11-2.005.543-2.684.937l.752 1.298c.592-.343 1.313-.664 2.085-.743l-.153-1.492Zm-2.052.881-.003-.001-.513 1.41h.003l.513-1.409Zm.459.49c.01.03-.008-.018.017-.195.021-.155.064-.342.12-.54.114-.398.26-.769.307-.878l-1.375-.599c-.072.164-.24.598-.374 1.066-.067.234-.13.495-.164.745-.032.227-.055.54.032.829l1.437-.429Zm.499-1.805a.752.752 0 0 0-.517-.825c-.109-.035-.2-.036-.22-.036a.975.975 0 0 0-.136.007 3.35 3.35 0 0 0-.382.076l.41 1.442c-.016.005.033-.007.104-.02l.045-.008-.016.001-.057.002a.744.744 0 0 1-.715-.854l1.484.215Zm-1.271-.773a6.786 6.786 0 0 0-1.116.458l.69 1.331c.281-.145.572-.264.87-.356l-.444-1.433Zm-1.113.456a8.23 8.23 0 0 0-1.147.714l.893 1.205c.299-.222.612-.417.938-.584l-.684-1.335Zm-1.146.713a8.266 8.266 0 0 0-2.109 2.291l1.274.792a6.767 6.767 0 0 1 1.726-1.876l-.89-1.207Zm-2.11 2.294a11.478 11.478 0 0 0-.425.752l1.334.686c.186-.361.364-.645.368-.652l-1.277-.786Zm-.421.744c-.034.065-.068.13-.1.195l1.34.675c.027-.057.056-.113.086-.169l-1.326-.7Zm-.092.178a6.881 6.881 0 0 0-.24.5l1.374.604a5.43 5.43 0 0 1 .188-.395l-1.322-.709Zm-.237.494-.01.022a.223.223 0 0 1-.002.006s0 .001 0 0l1.372.607.004-.01.003-.007c.002-.003.002-.003 0-.001l-1.367-.617Zm-.013.032c-.04.09-.08.181-.118.273l1.382.583.11-.256-1.374-.6Zm-.131.308.001-.005-.002.005c0 .003-.003.009-.006.015a1.203 1.203 0 0 0-.01.026l1.41.513a.11.11 0 0 0-.002.005l.003-.005a.913.913 0 0 0 .016-.04l-1.41-.514Zm-.016.04c-1.756 4.826.483 10.155 5.124 11.844l.513-1.41c-3.783-1.376-5.73-5.791-4.228-9.92l-1.41-.513Zm5.124 11.844c4.155 1.512 8.716-.451 10.88-4.338l-1.31-.73c-1.845 3.314-5.667 4.892-9.057 3.659l-.513 1.41Zm10.893-4.361c.054-.106.109-.22.154-.31l-1.346-.664c-.054.11-.096.198-.143.29l1.335.684Zm.154-.31c.705-1.432 1.1-3.074 1.065-4.603l-1.5.035c.03 1.262-.3 2.666-.911 3.905l1.346.662Z" fill="context-stroke" mask="url(#b)"/>
+ <defs>
+ <linearGradient id="a" x1="90.259" y1="57.79" x2="10.599" y2="18.261" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#FF9AA2"/>
+ <stop offset="1" stop-color="#9059FF"/>
+ </linearGradient>
+ </defs>
+</svg>
diff --git a/browser/components/firefoxview/content/synced-tabs-error.svg b/browser/components/firefoxview/content/synced-tabs-error.svg
new file mode 100644
index 0000000000..b2a322ef74
--- /dev/null
+++ b/browser/components/firefoxview/content/synced-tabs-error.svg
@@ -0,0 +1,30 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg width="156" height="113" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <g style="mix-blend-mode:luminosity">
+ <path opacity=".12" d="M75.2 88.827c-23.912-4.903-48.215 17.067-58.61 2.777-7.63-10.544 7.165-18.412-11.284-39.68S21.915 2.628 45.501 13.082 97.069-5.053 121.69.999c11.415 2.836 21.976 11.538 18.709 20.606-1.355 3.749-4.912 7.035-5.253 10.907-.66 7.553 13.624 17.47 15.428 24.873C159.05 92.177 99.111 93.73 75.2 88.827Z" fill="url(#a)"/>
+ <path stroke="context-stroke" stroke-linecap="round" d="M3.333 76.431h84.166M13.522 80.072h84.166"/>
+ <path d="M131.941 39.874h4.018c.73 0 1.278.913.73 1.643l-5.296 5.113h-1.643l-5.113-5.295c-.548-.548-.183-1.461.548-1.461h4.017c0-5.844-4.748-10.592-10.591-10.592-2.557 0-4.931.913-6.939 2.557-.548.548-1.461.365-2.009-.183-.183-.365-.183-.547-.183-.913 0-.365.183-.73.548-1.095 2.374-2.009 5.478-3.105 8.765-3.105 7.122 0 13.148 6.026 13.148 13.33ZM109.376 98.527h-4.018c0 5.844 4.748 10.591 10.592 10.591 2.556 0 4.93-.913 6.939-2.556.547-.548 1.461-.365 2.008.183.548.547.366 1.46-.182 2.008-2.374 2.009-5.479 3.105-8.583 3.105-7.304 0-13.33-6.026-13.33-13.33h-4.018c-.73 0-1.278-.914-.73-1.644l5.113-5.113h1.643l5.113 5.295c.548.548.183 1.461-.547 1.461Z" fill="#CB9EFF"/>
+ <path d="M25.308 77.432h80.247a1.5 1.5 0 0 1 1.258.684l4.964 7.646c.648.998-.068 2.317-1.258 2.317H20.344c-1.19 0-1.906-1.319-1.258-2.317l4.964-7.646a1.5 1.5 0 0 1 1.258-.684Z" fill="context-fill" stroke="context-stroke"/>
+ <rect x="24.169" y="22.567" width="82.527" height="57.05" rx="6.5" fill="context-fill" stroke="context-stroke"/>
+ <rect x="27.933" y="26.331" width="74.998" height="49.52" rx="4.375" fill="context-fill" stroke="context-stroke" stroke-width=".75"/>
+ <path d="M28.308 30.706a4 4 0 0 1 4-4h66.247a4 4 0 0 1 4 4v5.463H28.308v-5.462Z" fill="#CB9EFF"/>
+ <path stroke="context-stroke" stroke-width=".75" d="M28.308 36.522h74.975"/>
+ <rect x="31.699" y="29.368" width="19.332" height="4.357" rx=".873" fill="context-fill" stroke="context-stroke" stroke-width=".5"/>
+ <rect x="55.65" y="29.368" width="19.332" height="4.357" rx=".873" fill="context-fill" stroke="context-stroke" stroke-width=".5"/>
+ <path stroke="context-stroke" stroke-linecap="round" d="M135.232 98.218h19.269M134.566 100.365h16.885"/>
+ <rect x="116.823" y="98.075" width="45.403" height="27.933" rx="6.5" transform="rotate(-90 116.823 98.075)" fill="context-fill" stroke="context-stroke"/>
+ <rect x="119.858" y="95.039" width="39.33" height="21.86" rx="4.375" transform="rotate(-90 119.858 95.039)" fill="context-fill" stroke="context-stroke" stroke-width=".75"/>
+ <path d="M120.233 63.363v-3.279a4 4 0 0 1 4-4h13.11a4 4 0 0 1 4 4v3.28h-21.11Z" fill="#CB9EFF"/>
+ <path stroke="context-stroke" stroke-width=".75" d="M120.235 63.716h21.11"/>
+ <rect x="122.669" y="57.791" width="16.242" height="3.868" rx=".5" fill="context-fill" stroke="context-stroke" stroke-width=".5"/>
+ <circle cx="131.156" cy="90.296" r="2.662" fill="#FFBDC5" stroke="context-stroke" stroke-width=".5"/>
+ </g>
+ <defs>
+ <linearGradient id="a" x1=".527" y1="49.627" x2="150.86" y2="44.602" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#7542E4"/>
+ <stop offset="1" stop-color="#FF9AA2"/>
+ </linearGradient>
+ </defs>
+</svg> \ No newline at end of file
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..3f9056a7cd
--- /dev/null
+++ b/browser/components/firefoxview/firefox-view-notification-manager.sys.mjs
@@ -0,0 +1,112 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * This module exports the FirefoxViewNotificationManager singleton, which manages the notification state
+ * for the Firefox View button
+ */
+
+const RECENT_TABS_SYNC = "services.sync.lastTabFetch";
+const SHOULD_NOTIFY_FOR_TABS = "browser.tabs.firefox-view.notify-for-tabs";
+const lazy = {};
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs",
+ SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs",
+});
+
+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-places-query.sys.mjs b/browser/components/firefoxview/firefox-view-places-query.sys.mjs
new file mode 100644
index 0000000000..8923905769
--- /dev/null
+++ b/browser/components/firefoxview/firefox-view-places-query.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/. */
+
+import { PlacesQuery } from "resource://gre/modules/PlacesQuery.sys.mjs";
+
+/**
+ * Extension of PlacesQuery which provides additional caches for Firefox View.
+ */
+export class FirefoxViewPlacesQuery extends PlacesQuery {
+ /** @type {Date} */
+ #todaysDate = null;
+ /** @type {Date} */
+ #yesterdaysDate = null;
+
+ get visitsFromToday() {
+ if (this.cachedHistory == null || this.#todaysDate == null) {
+ return [];
+ }
+ const mapKey = this.getStartOfDayTimestamp(this.#todaysDate);
+ return this.cachedHistory.get(mapKey) ?? [];
+ }
+
+ get visitsFromYesterday() {
+ if (this.cachedHistory == null || this.#yesterdaysDate == null) {
+ return [];
+ }
+ const mapKey = this.getStartOfDayTimestamp(this.#yesterdaysDate);
+ return this.cachedHistory.get(mapKey) ?? [];
+ }
+
+ /**
+ * Get a list of visits per day for each day on this month, excluding today
+ * and yesterday.
+ *
+ * @returns {HistoryVisit[][]}
+ * A list of visits for each day.
+ */
+ get visitsByDay() {
+ const visitsPerDay = [];
+ for (const [time, visits] of this.cachedHistory.entries()) {
+ const date = new Date(time);
+ if (
+ this.#isSameDate(date, this.#todaysDate) ||
+ this.#isSameDate(date, this.#yesterdaysDate)
+ ) {
+ continue;
+ } else if (!this.#isSameMonth(date, this.#todaysDate)) {
+ break;
+ } else {
+ visitsPerDay.push(visits);
+ }
+ }
+ return visitsPerDay;
+ }
+
+ /**
+ * Get a list of visits per month for each month, excluding this one, and
+ * excluding yesterday's visits if yesterday happens to fall on the previous
+ * month.
+ *
+ * @returns {HistoryVisit[][]}
+ * A list of visits for each month.
+ */
+ get visitsByMonth() {
+ const visitsPerMonth = [];
+ let previousMonth = null;
+ for (const [time, visits] of this.cachedHistory.entries()) {
+ const date = new Date(time);
+ if (
+ this.#isSameMonth(date, this.#todaysDate) ||
+ this.#isSameDate(date, this.#yesterdaysDate)
+ ) {
+ continue;
+ }
+ const month = this.getStartOfMonthTimestamp(date);
+ if (month !== previousMonth) {
+ visitsPerMonth.push(visits);
+ } else {
+ visitsPerMonth[visitsPerMonth.length - 1] = visitsPerMonth
+ .at(-1)
+ .concat(visits);
+ }
+ previousMonth = month;
+ }
+ return visitsPerMonth;
+ }
+
+ formatRowAsVisit(row) {
+ const visit = super.formatRowAsVisit(row);
+ this.#normalizeVisit(visit);
+ return visit;
+ }
+
+ formatEventAsVisit(event) {
+ const visit = super.formatEventAsVisit(event);
+ this.#normalizeVisit(visit);
+ return visit;
+ }
+
+ /**
+ * Normalize data for fxview-tabs-list.
+ *
+ * @param {HistoryVisit} visit
+ * The visit to format.
+ */
+ #normalizeVisit(visit) {
+ visit.time = visit.date.getTime();
+ visit.title = visit.title || visit.url;
+ visit.icon = `page-icon:${visit.url}`;
+ visit.primaryL10nId = "fxviewtabrow-tabs-list-tab";
+ visit.primaryL10nArgs = JSON.stringify({
+ targetURI: visit.url,
+ });
+ visit.secondaryL10nId = "fxviewtabrow-options-menu-button";
+ visit.secondaryL10nArgs = JSON.stringify({
+ tabTitle: visit.title || visit.url,
+ });
+ }
+
+ async fetchHistory() {
+ await super.fetchHistory();
+ if (this.cachedHistoryOptions.sortBy === "date") {
+ this.#setTodaysDate();
+ }
+ }
+
+ handlePageVisited(event) {
+ const visit = super.handlePageVisited(event);
+ if (!visit) {
+ return;
+ }
+ if (
+ this.cachedHistoryOptions.sortBy === "date" &&
+ (this.#todaysDate == null ||
+ (visit.date.getTime() > this.#todaysDate.getTime() &&
+ !this.#isSameDate(visit.date, this.#todaysDate)))
+ ) {
+ // If today's date has passed (or is null), it should be updated now.
+ this.#setTodaysDate();
+ }
+ }
+
+ #setTodaysDate() {
+ const now = new Date();
+ this.#todaysDate = new Date(
+ now.getFullYear(),
+ now.getMonth(),
+ now.getDate()
+ );
+ this.#yesterdaysDate = new Date(
+ now.getFullYear(),
+ now.getMonth(),
+ now.getDate() - 1
+ );
+ }
+
+ /**
+ * Given two date instances, check if their dates are equivalent.
+ *
+ * @param {Date} dateToCheck
+ * @param {Date} date
+ * @returns {boolean}
+ * Whether both date instances have equivalent dates.
+ */
+ #isSameDate(dateToCheck, date) {
+ return (
+ dateToCheck.getDate() === date.getDate() &&
+ this.#isSameMonth(dateToCheck, date)
+ );
+ }
+
+ /**
+ * Given two date instances, check if their months are equivalent.
+ *
+ * @param {Date} dateToCheck
+ * @param {Date} month
+ * @returns {boolean}
+ * Whether both date instances have equivalent months.
+ */
+ #isSameMonth(dateToCheck, month) {
+ return (
+ dateToCheck.getMonth() === month.getMonth() &&
+ dateToCheck.getFullYear() === month.getFullYear()
+ );
+ }
+}
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..ee82bec3ca
--- /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",
+});
+
+ChromeUtils.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-disabled-by-policy-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-description2",
+ 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..4c43eea1b6
--- /dev/null
+++ b/browser/components/firefoxview/firefox-view-tabs-setup-manager.sys.mjs
@@ -0,0 +1,653 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * This module exports the TabsSetupFlowManager singleton, which manages the state and
+ * diverse inputs which drive the Firefox View synced tabs setup flow
+ */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ Log: "resource://gre/modules/Log.sys.mjs",
+ 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",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "syncUtils", () => {
+ return ChromeUtils.importESModule("resource://services-sync/util.sys.mjs")
+ .Utils;
+});
+
+ChromeUtils.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 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";
+
+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);
+ }
+ );
+
+ 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;
+ }
+
+ // 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
+ 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 new Promise(resolve => {
+ ChromeUtils.idleDispatch(() => {
+ resolve(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();
+ }
+ }
+
+ 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_next",
+ "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_next",
+ "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.css b/browser/components/firefoxview/firefoxview.css
new file mode 100644
index 0000000000..48cf5a9490
--- /dev/null
+++ b/browser/components/firefoxview/firefoxview.css
@@ -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/. */
+
+ @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-secondary-color: color-mix(in srgb, currentColor 70%, transparent);
+ --fxview-text-color-hover: var(--newtab-text-primary-color);
+ --fxview-primary-action-background: var(--newtab-primary-action-background, #0061e0);
+ --fxview-border: var(--fc-border-light, #CFCFD8);
+
+ /* 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: 288px;
+ --fxview-margin-top: 72px;
+ --fxview-card-padding-inline: 4px;
+
+ /* copy over newtab background color from activity-stream-[os].css files */
+ --newtab-background-color: #F9F9FB;
+
+ --fxview-card-header-font-weight: 500;
+}
+
+@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);
+ --fxview-border: #8F8F9D;
+
+ /* copy over newtab colors from activity-stream-[os].css files */
+ --newtab-background-color: #2B2A33;
+ --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;
+ --fxview-border: var(--fc-border-hcm, -moz-dialogtext);
+ --newtab-primary-action-background: LinkText;
+ --newtab-background-color: Canvas;
+ --newtab-background-color-secondary: Canvas;
+ }
+}
+
+@media (max-width: 52rem) {
+ :root {
+ --fxview-sidebar-width: 82px;
+ }
+}
+
+body {
+ display: grid;
+ gap: 12px;
+ grid-template-columns: var(--fxview-sidebar-width) 1fr;
+ background-color: var(--fxview-background-color);
+ color: var(--fxview-text-primary-color);
+}
+
+.main-container {
+ width: 90%;
+ margin: 0 auto;
+ min-width: 43rem;
+ max-width: 71rem;
+}
+
+@media (min-width: 120rem) {
+ .main-container {
+ margin-inline-start: 148px;
+ }
+}
+
+.page-header {
+ margin: 0;
+}
+
+fxview-category-button:focus-visible {
+ outline-offset: var(--in-content-focus-outline-inset);
+}
+
+fxview-category-button[name="recentbrowsing"]::part(icon) {
+ background-image: url("chrome://browser/content/firefoxview/category-recentbrowsing.svg");
+}
+fxview-category-button[name="opentabs"]::part(icon) {
+ background-image: url("chrome://browser/content/firefoxview/category-opentabs.svg");
+}
+fxview-category-button[name="recentlyclosed"]::part(icon) {
+ background-image: url("chrome://browser/content/firefoxview/category-recentlyclosed.svg");
+}
+fxview-category-button[name="syncedtabs"]::part(icon) {
+ background-image: url("chrome://browser/content/firefoxview/category-syncedtabs.svg");
+}
+fxview-category-button[name="history"]::part(icon) {
+ background-image: url("chrome://browser/content/firefoxview/category-history.svg");
+}
+
+fxview-tab-list.with-dismiss-button::part(secondary-button) {
+ background-image: url("chrome://global/skin/icons/close.svg");
+}
+
+fxview-tab-list.with-context-menu::part(secondary-button) {
+ background-image: url("chrome://global/skin/icons/more.svg");
+}
+
+.sticky-container {
+ position: sticky;
+ top: 0;
+ padding-block: var(--fxview-margin-top) 33px;
+ z-index: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 35px;
+}
+
+.sticky-container.bottom-fade {
+ /*
+ * padding-inline is doubled to allow for the negative margin below to offset the
+ * container so that the box-shadows on the cards are hidden as they pass underneath.
+ */
+ padding-inline: calc(var(--fxview-card-padding-inline) * 2);
+ margin: 0 calc(var(--fxview-card-padding-inline) * -1);
+
+ background:
+ linear-gradient(
+ to bottom,
+ var(--fxview-background-color) 0%,
+ var(--fxview-background-color) 95%,
+ transparent 100%
+ );
+ /* When you use HCM or set custom colors, you can't use a gradient. */
+ @media (forced-colors) {
+ background: var(--fxview-background-color);
+ }
+}
+
+.cards-container {
+ padding-inline: var(--fxview-card-padding-inline);
+}
+
+view-opentabs-contextmenu {
+ display: contents;
+}
+
+/* This should be supported within panel-{item,list} rather than modifying it */
+panel-item::part(button) {
+ padding-inline-start: 12px;
+ cursor: pointer;
+}
+
+panel-item::part(button):hover {
+ background-color: var(--fxview-element-background-hover);
+ color: var(--fxview-text-color-hover);
+}
+
+panel-item::part(button):hover:active {
+ background-color: var(--fxview-element-background-active);
+}
+
+panel-list {
+ overflow-y: visible;
+}
+
+fxview-category-navigation {
+ overflow-y: auto;
+}
+
+fxview-category-navigation h1 {
+ margin-block: 0;
+}
+
+fxview-empty-state:not([isSelectedTab]) button[slot="primary-action"] {
+ margin-inline-start: 0;
+}
diff --git a/browser/components/firefoxview/firefoxview.html b/browser/components/firefoxview/firefoxview.html
new file mode 100644
index 0000000000..1f53a1d0c9
--- /dev/null
+++ b/browser/components/firefoxview/firefoxview.html
@@ -0,0 +1,118 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <meta
+ http-equiv="Content-Security-Policy"
+ content="default-src resource: chrome:; object-src 'none'; img-src data: chrome:;"
+ />
+ <meta name="color-scheme" content="light dark" />
+ <title data-l10n-id="firefoxview-page-title"></title>
+ <link rel="localization" href="branding/brand.ftl" />
+ <link rel="localization" href="toolkit/branding/accounts.ftl" />
+ <link rel="localization" href="browser/firefoxView.ftl" />
+ <link rel="localization" href="branding/brand.ftl" />
+ <link rel="localization" href="toolkit/branding/accounts.ftl" />
+ <link rel="localization" href="toolkit/branding/brandings.ftl" />
+ <link rel="localization" href="browser/migrationWizard.ftl" />
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/firefoxview/firefoxview.css"
+ />
+ <script
+ type="module"
+ src="chrome://browser/content/firefoxview/recentbrowsing.mjs"
+ ></script>
+ <script
+ type="module"
+ src="chrome://browser/content/firefoxview/history.mjs"
+ ></script>
+ <script
+ type="module"
+ src="chrome://browser/content/firefoxview/opentabs.mjs"
+ ></script>
+ <script
+ type="module"
+ src="chrome://browser/content/firefoxview/recentlyclosed.mjs"
+ ></script>
+ <script
+ type="module"
+ src="chrome://browser/content/firefoxview/fxview-category-navigation.mjs"
+ ></script>
+ <script
+ type="module"
+ src="chrome://browser/content/firefoxview/syncedtabs.mjs"
+ ></script>
+ <script src="chrome://browser/content/contentTheme.js"></script>
+ </head>
+
+ <body>
+ <fxview-category-navigation>
+ <h1 slot="category-nav-header" data-l10n-id="firefoxview-page-title"></h1>
+ <fxview-category-button
+ class="category"
+ slot="category-button"
+ name="recentbrowsing"
+ data-l10n-id="firefoxview-overview-nav"
+ >
+ </fxview-category-button>
+ <fxview-category-button
+ class="category"
+ slot="category-button"
+ name="opentabs"
+ data-l10n-id="firefoxview-opentabs-nav"
+ >
+ </fxview-category-button>
+ <fxview-category-button
+ class="category"
+ slot="category-button"
+ name="recentlyclosed"
+ data-l10n-id="firefoxview-recently-closed-nav"
+ >
+ </fxview-category-button>
+ <fxview-category-button
+ class="category"
+ slot="category-button"
+ name="syncedtabs"
+ data-l10n-id="firefoxview-synced-tabs-nav"
+ >
+ </fxview-category-button>
+ <fxview-category-button
+ class="category"
+ slot="category-button"
+ name="history"
+ data-l10n-id="firefoxview-history-nav"
+ >
+ </fxview-category-button>
+ </fxview-category-navigation>
+ <main id="pages" role="application" data-l10n-id="firefoxview-page-label">
+ <div class="main-container">
+ <named-deck>
+ <view-recentbrowsing name="recentbrowsing" type="page">
+ <div>
+ <view-opentabs slot="opentabs"></view-opentabs>
+ </div>
+ <div>
+ <view-recentlyclosed slot="recentlyclosed"></view-recentlyclosed>
+ </div>
+ <div>
+ <view-syncedtabs slot="syncedtabs"></view-syncedtabs>
+ </div>
+ </view-recentbrowsing>
+ <view-history name="history" type="page"></view-history>
+ <view-opentabs name="opentabs" type="page"></view-opentabs>
+ <view-recentlyclosed
+ name="recentlyclosed"
+ type="page"
+ ></view-recentlyclosed>
+ <view-syncedtabs name="syncedtabs" type="page"></view-syncedtabs>
+ </named-deck>
+ </div>
+ </main>
+ <script src="chrome://browser/content/firefoxview/firefoxview.mjs"></script>
+ </body>
+</html>
diff --git a/browser/components/firefoxview/firefoxview.mjs b/browser/components/firefoxview/firefoxview.mjs
new file mode 100644
index 0000000000..77f4c06cc7
--- /dev/null
+++ b/browser/components/firefoxview/firefoxview.mjs
@@ -0,0 +1,189 @@
+/* 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 = [];
+let categoryPagesDeck = null;
+let categoryNavigation = null;
+let activeComponent = null;
+let searchKeyboardShortcut = null;
+
+const { topChromeWindow } = window.browsingContext;
+
+function onHashChange() {
+ let page = document.location?.hash.substring(1);
+ if (!page || !pageList.includes(page)) {
+ page = "recentbrowsing";
+ }
+ changePage(page);
+}
+
+function changePage(page) {
+ categoryPagesDeck.selectedViewName = page;
+ categoryNavigation.currentCategory = page;
+ if (categoryNavigation.categoryButtons.includes(document.activeElement)) {
+ let currentCategoryButton = categoryNavigation.categoryButtons.find(
+ categoryButton => categoryButton.name === page
+ );
+ (currentCategoryButton || categoryNavigation.categoryButtons[0]).focus();
+ }
+}
+
+function onPagesDeckViewChange() {
+ for (const child of categoryPagesDeck.children) {
+ if (child.getAttribute("name") == categoryPagesDeck.selectedViewName) {
+ child.enter();
+ activeComponent = child;
+ } else {
+ child.exit();
+ }
+ }
+}
+
+function recordNavigationTelemetry(source, eventTarget) {
+ let page = "recentbrowsing";
+ if (source === "category-navigation") {
+ page = eventTarget.parentNode.currentCategory;
+ } else if (source === "view-all") {
+ page = eventTarget.shortPageName;
+ }
+ // Record telemetry
+ Services.telemetry.recordEvent(
+ "firefoxview_next",
+ "change_page",
+ "navigation",
+ null,
+ {
+ page,
+ source,
+ }
+ );
+}
+
+async function updateSearchTextboxSize() {
+ const msgs = [
+ { id: "firefoxview-search-text-box-recentbrowsing" },
+ { id: "firefoxview-search-text-box-opentabs" },
+ { id: "firefoxview-search-text-box-recentlyclosed" },
+ { id: "firefoxview-search-text-box-syncedtabs" },
+ { id: "firefoxview-search-text-box-history" },
+ ];
+ let maxLength = 30;
+ for (const msg of await document.l10n.formatMessages(msgs)) {
+ const placeholder = msg.attributes[0].value;
+ maxLength = Math.max(maxLength, placeholder.length);
+ }
+ for (const child of categoryPagesDeck.children) {
+ child.searchTextboxSize = maxLength;
+ }
+}
+
+async function updateSearchKeyboardShortcut() {
+ const [message] = await topChromeWindow.document.l10n.formatMessages([
+ { id: "find-shortcut" },
+ ]);
+ const key = message.attributes[0].value;
+ searchKeyboardShortcut = key.toLocaleLowerCase();
+}
+
+window.addEventListener("DOMContentLoaded", async () => {
+ recordEnteredTelemetry();
+
+ categoryNavigation = document.querySelector("fxview-category-navigation");
+ categoryPagesDeck = document.querySelector("named-deck");
+
+ for (const item of categoryNavigation.categoryButtons) {
+ pageList.push(item.getAttribute("name"));
+ }
+ window.addEventListener("hashchange", onHashChange);
+ window.addEventListener("change-category", function (event) {
+ location.hash = event.target.getAttribute("name");
+ window.scrollTo(0, 0);
+ recordNavigationTelemetry("category-navigation", event.target);
+ });
+ window.addEventListener("card-container-view-all", function (event) {
+ recordNavigationTelemetry("view-all", event.originalTarget);
+ });
+
+ categoryPagesDeck.addEventListener("view-changed", onPagesDeckViewChange);
+
+ // set the initial state
+ onHashChange();
+ onPagesDeckViewChange();
+ await updateSearchTextboxSize();
+ await updateSearchKeyboardShortcut();
+
+ if (Cu.isInAutomation) {
+ Services.obs.notifyObservers(null, "firefoxview-entered");
+ }
+});
+
+document.addEventListener("visibilitychange", () => {
+ if (document.visibilityState === "visible") {
+ recordEnteredTelemetry();
+ if (Cu.isInAutomation) {
+ // allow all the component visibilitychange handlers to execute before notifying
+ requestAnimationFrame(() => {
+ Services.obs.notifyObservers(null, "firefoxview-entered");
+ });
+ }
+ }
+});
+
+function recordEnteredTelemetry() {
+ Services.telemetry.recordEvent(
+ "firefoxview_next",
+ "entered",
+ "firefoxview",
+ null,
+ {
+ page: document.location?.hash?.substring(1) || "recentbrowsing",
+ }
+ );
+}
+
+document.addEventListener("keydown", e => {
+ if (e.getModifierState("Accel") && e.key === searchKeyboardShortcut) {
+ activeComponent.searchTextbox?.focus();
+ }
+});
+
+window.addEventListener(
+ "unload",
+ () => {
+ // Clear out the document so the disconnectedCallback will trigger
+ // properly and all of the custom elements can cleanup.
+ document.body.textContent = "";
+ topChromeWindow.removeEventListener("command", onCommand);
+ Services.obs.removeObserver(onLocalesChanged, "intl:app-locales-changed");
+ },
+ { once: true }
+);
+
+topChromeWindow.addEventListener("command", onCommand);
+Services.obs.addObserver(onLocalesChanged, "intl:app-locales-changed");
+
+function onCommand(e) {
+ if (document.hidden || !e.target.closest("#contentAreaContextMenu")) {
+ return;
+ }
+ const item =
+ e.target.closest("#context-openlinkinusercontext-menu") || e.target;
+ Services.telemetry.recordEvent(
+ "firefoxview_next",
+ "browser_context_menu",
+ "tabs",
+ null,
+ {
+ menu_action: item.id,
+ page: location.hash?.substring(1) || "recentbrowsing",
+ }
+ );
+}
+
+function onLocalesChanged() {
+ requestIdleCallback(() => {
+ updateSearchTextboxSize();
+ updateSearchKeyboardShortcut();
+ });
+}
diff --git a/browser/components/firefoxview/fxview-category-button.css b/browser/components/firefoxview/fxview-category-button.css
new file mode 100644
index 0000000000..1bce29f343
--- /dev/null
+++ b/browser/components/firefoxview/fxview-category-button.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/. */
+
+: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:hover {
+ cursor: pointer;
+}
+
+@media not (prefers-contrast) {
+ button {
+ border-inline-start: 2px solid transparent;
+ border-inline-end: none;
+ border-block: none;
+ }
+
+ button:hover,
+ button[selected]:hover {
+ background-color: var(--in-content-button-background-hover);
+ border-color: var(--in-content-button-border-color-hover);
+ }
+
+ button[selected]:hover {
+ border-inline-start-color: inherit;
+ }
+
+ button[selected],
+ button[selected]:hover {
+ border-inline-start: 2px solid;
+ }
+
+ button[selected]:not(:focus-visible) {
+ border-start-start-radius: 0;
+ border-end-start-radius: 0;
+ }
+
+ button[selected]:not(:hover) {
+ color: var(--in-content-accent-color);
+ background-color: color-mix(in srgb, var(--fxview-primary-action-background) 5%, transparent);
+ border-inline-start-color: var(--in-content-accent-color);
+ }
+}
+
+@media (prefers-color-scheme: dark) {
+ button[selected] {
+ background-color: color-mix(in srgb, var(--fxview-primary-action-background) 12%, transparent);
+ }
+}
+
+button:focus-visible,
+button[selected]: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;
+ }
+}
+
+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..571059699b
--- /dev/null
+++ b/browser/components/firefoxview/fxview-category-navigation.css
@@ -0,0 +1,60 @@
+/* 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;
+ position: sticky;
+ top: 0;
+ height: 100vh;
+}
+
+nav {
+ display: grid;
+ grid-template-rows: min-content 1fr auto;
+ gap: 25px;
+ margin-block-start: var(--fxview-margin-top);
+}
+
+.category-nav-header {
+ /* Align the header text/icon with the category button icons */
+ margin-inline-start: var(--fxviewcategorynav-button-padding);
+}
+
+.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) {
+ /* (See Bug 1610081) Setting border-inline-end to add clear differentiation between side navigation and main content area */
+ :host {
+ border-inline-end: 1px solid 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..abacd17df1
--- /dev/null
+++ b/browser/components/firefoxview/fxview-category-navigation.mjs
@@ -0,0 +1,150 @@
+/* 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 {
+ static properties = {
+ currentCategory: { type: String },
+ };
+
+ static queries = {
+ categoryButtonsSlot: "slot[name=category-button]",
+ };
+
+ get categoryButtons() {
+ return this.categoryButtonsSlot
+ .assignedNodes()
+ .filter(node => !node.hidden);
+ }
+
+ onChangeCategory(e) {
+ this.currentCategory = e.target.name;
+ }
+
+ handleFocus(e) {
+ if (e.key == "ArrowDown" || e.key == "ArrowRight") {
+ e.preventDefault();
+ this.focusNextCategory();
+ } else if (e.key == "ArrowUp" || e.key == "ArrowLeft") {
+ e.preventDefault();
+ this.focusPreviousCategory();
+ }
+ }
+
+ focusPreviousCategory() {
+ let categoryButtons = this.categoryButtons;
+ let currentIndex = categoryButtons.findIndex(b => b.selected);
+ let prev = categoryButtons[currentIndex - 1];
+ if (prev) {
+ prev.activate();
+ prev.focus();
+ }
+ }
+
+ focusNextCategory() {
+ let categoryButtons = this.categoryButtons;
+ let currentIndex = categoryButtons.findIndex(b => b.selected);
+ let next = categoryButtons[currentIndex + 1];
+ if (next) {
+ next.activate();
+ next.focus();
+ }
+ }
+
+ render() {
+ return html`
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/firefoxview/fxview-category-navigation.css"
+ />
+ <nav>
+ <div class="category-nav-header">
+ <slot name="category-nav-header"></slot>
+ </div>
+ <div
+ class="category-nav-buttons"
+ role="tablist"
+ aria-orientation="vertical"
+ >
+ <slot
+ name="category-button"
+ @change-category=${this.onChangeCategory}
+ @keydown=${this.handleFocus}
+ ></slot>
+ </div>
+ <div class="category-nav-footer">
+ <slot name="category-nav-footer"></slot>
+ </div>
+ </nav>
+ `;
+ }
+
+ updated() {
+ let categorySelected = false;
+ let assignedCategories = this.categoryButtons;
+ for (let button of assignedCategories) {
+ button.selected = button.name == this.currentCategory;
+ categorySelected = categorySelected || button.selected;
+ }
+ if (!categorySelected && assignedCategories.length) {
+ // Current category has no matching category, reset to the first category.
+ assignedCategories[0].activate();
+ }
+ }
+}
+customElements.define("fxview-category-navigation", FxviewCategoryNavigation);
+
+export class FxviewCategoryButton extends MozLitElement {
+ static properties = {
+ selected: { type: Boolean },
+ };
+
+ static queries = {
+ buttonEl: "button",
+ };
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.setAttribute("role", "tab");
+ }
+
+ get name() {
+ return this.getAttribute("name");
+ }
+
+ activate() {
+ this.dispatchEvent(
+ new CustomEvent("change-category", {
+ bubbles: true,
+ composed: true,
+ })
+ );
+ }
+
+ render() {
+ return html`
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/firefoxview/fxview-category-button.css"
+ />
+ <button
+ aria-hidden="true"
+ tabindex="-1"
+ ?selected=${this.selected}
+ @click=${this.activate}
+ >
+ <span class="category-icon" part="icon"></span>
+ <slot></slot>
+ </button>
+ `;
+ }
+
+ updated() {
+ this.setAttribute("aria-selected", this.selected);
+ this.setAttribute("tabindex", this.selected ? 0 : -1);
+ }
+}
+customElements.define("fxview-category-button", FxviewCategoryButton);
diff --git a/browser/components/firefoxview/fxview-empty-state.css b/browser/components/firefoxview/fxview-empty-state.css
new file mode 100644
index 0000000000..80b4099e6a
--- /dev/null
+++ b/browser/components/firefoxview/fxview-empty-state.css
@@ -0,0 +1,99 @@
+/* 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/design-system/text-and-typography.css");
+
+[slot="main"] {
+ display: flex;
+ gap: 40px;
+ align-items: center;
+ padding: 36px;
+}
+
+[slot="main"].selectedTab {
+ flex-direction: column;
+ text-align: center;
+ gap: 22px;
+ height: 264px;
+}
+
+[slot="main"].selectedTab .header {
+ justify-content: center;
+}
+
+[slot="main"].imageHidden .image-container {
+ display: none;
+}
+
+[slot="main"].imageHidden .main {
+ display: flex;
+ flex: 1;
+ justify-content: center;
+}
+
+.image-container {
+ min-width: 150px;
+ text-align: center;
+}
+
+.image {
+ -moz-context-properties: fill, stroke, fill-opacity;
+ fill: var(--fxview-background-color-secondary);
+ stroke: var(--fxview-text-primary-color);
+}
+
+.header {
+ margin-block: 0;
+ align-items: center;
+ gap: 8px;
+
+ :host(.search-results) & {
+ font-size: unset;
+
+ & span {
+ overflow-wrap: anywhere;
+ }
+ }
+
+ &:not([hidden]) {
+ display: flex;
+ }
+}
+
+.icon {
+ background-position: center center;
+ background-repeat: no-repeat;
+ width: 20px;
+ height: 20px;
+
+ &:not([hidden]) {
+ display: inline-block;
+ }
+}
+
+
+.info {
+ -moz-context-properties: fill;
+ fill: var(--in-content-primary-button-background);
+}
+
+.description {
+ color: var(--text-color-deemphasized);
+ margin-block: 4px 15px;
+}
+
+.description.secondary {
+ margin-block-start: 16px;
+}
+
+.main a {
+ color: var(--fxview-primary-action-background);
+}
+
+img.greyscale {
+ filter: grayscale(100%);
+ @media not (prefers-contrast) {
+ opacity: 0.5;
+ }
+}
diff --git a/browser/components/firefoxview/fxview-empty-state.mjs b/browser/components/firefoxview/fxview-empty-state.mjs
new file mode 100644
index 0000000000..9e6bc488fa
--- /dev/null
+++ b/browser/components/firefoxview/fxview-empty-state.mjs
@@ -0,0 +1,121 @@
+/* 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,
+ classMap,
+ repeat,
+} from "chrome://global/content/vendor/lit.all.mjs";
+import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
+
+/**
+ * An empty state card to be used throughout Firefox View
+ *
+ * @property {string} headerIconUrl - (Optional) The chrome:// url for an icon to be displayed within the header
+ * @property {string} headerLabel - (Optional) The l10n id for the header text for the empty/error state
+ * @property {object} headerArgs - (Optional) The l10n args for the header text for the empty/error state
+ * @property {string} isInnerCard - (Optional) True if the card is displayed within another card and needs a border instead of box shadow
+ * @property {boolean} isSelectedTab - (Optional) True if the component is the selected navigation tab - defaults to false
+ * @property {Array} descriptionLabels - (Optional) An array of l10n ids for the secondary description text for the empty/error state
+ * @property {object} descriptionLink - (Optional) An object describing the l10n name and url needed within a description label
+ * @property {string} mainImageUrl - (Optional) The chrome:// url for the main image of the empty/error state
+ * @property {string} errorGrayscale - (Optional) The image should be shown in gray scale
+ */
+class FxviewEmptyState extends MozLitElement {
+ constructor() {
+ super();
+ this.isSelectedTab = false;
+ this.descriptionLabels = [];
+ this.headerArgs = {};
+ }
+
+ static properties = {
+ headerLabel: { type: String },
+ headerArgs: { type: Object },
+ headerIconUrl: { type: String },
+ isInnerCard: { type: Boolean },
+ isSelectedTab: { type: Boolean },
+ descriptionLabels: { type: Array },
+ desciptionLink: { type: Object },
+ mainImageUrl: { type: String },
+ errorGrayscale: { type: Boolean },
+ };
+
+ static queries = {
+ headerEl: ".header",
+ descriptionEls: { all: ".description" },
+ };
+
+ linkTemplate(descriptionLink) {
+ if (!descriptionLink) {
+ return html``;
+ }
+ return html` <a
+ aria-details="card-container"
+ data-l10n-name=${descriptionLink.name}
+ href=${descriptionLink.url}
+ target=${descriptionLink?.sameTarget ? "_self" : "_blank"}
+ />`;
+ }
+
+ render() {
+ return html`
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/firefoxview/fxview-empty-state.css"
+ />
+ <card-container hideHeader="true" exportparts="image" ?isInnerCard="${
+ this.isInnerCard
+ }" id="card-container" isEmptyState="true">
+ <div slot="main" class=${classMap({
+ selectedTab: this.isSelectedTab,
+ imageHidden: !this.mainImageUrl,
+ })}>
+ <div class="image-container">
+ <img class=${classMap({
+ image: true,
+ greyscale: this.errorGrayscale,
+ })}
+ role="presentation"
+ alt=""
+ ?hidden=${!this.mainImageUrl}
+ src=${this.mainImageUrl}/>
+ </div>
+ <div class="main">
+ <h2
+ id="header"
+ class="header heading-large"
+ ?hidden=${!this.headerLabel}
+ >
+ <img class="icon info"
+ data-l10n-id="firefoxview-empty-state-icon"
+ ?hidden=${!this.headerIconUrl}
+ src=${ifDefined(this.headerIconUrl)}></img>
+ <span
+ data-l10n-id="${this.headerLabel}"
+ data-l10n-args="${JSON.stringify(this.headerArgs)}">
+ </span>
+ </h2>
+ ${repeat(
+ this.descriptionLabels,
+ descLabel => descLabel,
+ (descLabel, index) => html`<p
+ class=${classMap({
+ description: true,
+ secondary: index !== 0,
+ })}
+ data-l10n-id="${descLabel}"
+ >
+ ${this.linkTemplate(this.descriptionLink)}
+ </p>`
+ )}
+ <slot name="primary-action"></slot>
+ </div>
+ </div>
+ </card-container>
+ `;
+ }
+}
+customElements.define("fxview-empty-state", FxviewEmptyState);
diff --git a/browser/components/firefoxview/fxview-search-textbox.css b/browser/components/firefoxview/fxview-search-textbox.css
new file mode 100644
index 0000000000..82c33c8069
--- /dev/null
+++ b/browser/components/firefoxview/fxview-search-textbox.css
@@ -0,0 +1,78 @@
+/* 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/. */
+
+.search-container {
+ border: 1px solid var(--fxview-border);
+ border-radius: var(--border-radius-small);
+ color: var(--fxview-text-primary-color);
+ display: inline-flex;
+ overflow: hidden;
+ position: relative;
+
+ &:focus-within {
+ overflow: visible;
+ }
+}
+
+.search-icon {
+ background-image: url(chrome://global/skin/icons/search-textbox.svg);
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: 16px;
+ fill: currentColor;
+ -moz-context-properties: fill;
+ height: 16px;
+ width: 16px;
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ margin: auto 0;
+ padding: 2px;
+}
+
+.search-icon:dir(ltr) {
+ left: 8px;
+}
+
+.search-icon:dir(rtl) {
+ right: 8px;
+}
+
+input {
+ border: none;
+ padding-block-start: 8px;
+ padding-block-end: 8px;
+ padding-inline-start: 32px;
+ padding-inline-end: 32px;
+}
+
+.clear-icon {
+ background-image: url(chrome://global/skin/icons/close-12.svg);
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: 16px;
+ fill: currentColor;
+ -moz-context-properties: fill;
+ cursor: pointer;
+ height: 16px;
+ width: 16px;
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ margin: auto 0;
+ padding: 2px;
+}
+
+.clear-icon:hover {
+ background-color: var(--fxview-element-background-hover);
+ color: var(--fxview-text-color-hover);
+}
+
+.clear-icon:dir(ltr) {
+ right: 8px;
+}
+
+.clear-icon:dir(rtl) {
+ left: 8px;
+}
diff --git a/browser/components/firefoxview/fxview-search-textbox.mjs b/browser/components/firefoxview/fxview-search-textbox.mjs
new file mode 100644
index 0000000000..1332f5f3f6
--- /dev/null
+++ b/browser/components/firefoxview/fxview-search-textbox.mjs
@@ -0,0 +1,143 @@
+/* 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";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
+});
+
+const SEARCH_DEBOUNCE_RATE_MS = 500;
+const SEARCH_DEBOUNCE_TIMEOUT_MS = 1000;
+
+/**
+ * A search box that displays a search icon and is clearable. Updates to the
+ * search query trigger a `fxview-search-textbox-query` event with the current
+ * query value.
+ *
+ * There is no actual searching done here. That needs to be implemented by the
+ * `fxview-search-textbox-query` event handler. `searchTabList()` from
+ * `helpers.mjs` can be used as a starting point.
+ *
+ * @property {string} placeholder
+ * The placeholder text for the search box.
+ * @property {number} size
+ * The width (number of characters) of the search box.
+ * @property {string} pageName
+ * The hash for the page name that the search input is located on.
+ */
+export default class FxviewSearchTextbox extends MozLitElement {
+ static properties = {
+ placeholder: { type: String },
+ size: { type: Number },
+ pageName: { type: String },
+ };
+
+ static queries = {
+ clearButton: ".clear-icon",
+ input: "input",
+ };
+
+ #query = "";
+
+ constructor() {
+ super();
+ this.searchTask = new lazy.DeferredTask(
+ () => this.#dispatchQueryEvent(),
+ SEARCH_DEBOUNCE_RATE_MS,
+ SEARCH_DEBOUNCE_TIMEOUT_MS
+ );
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ if (!this.searchTask?.isFinalized) {
+ this.searchTask?.finalize();
+ }
+ }
+
+ focus() {
+ this.input.focus();
+ }
+
+ blur() {
+ this.input.blur();
+ }
+
+ onInput(event) {
+ this.#query = event.target.value.trim();
+ event.preventDefault();
+ this.onSearch();
+ }
+
+ /**
+ * Handler for query updates from keyboard input, and textbox clears from 'X'
+ * button.
+ */
+ onSearch() {
+ this.searchTask?.arm();
+ this.requestUpdate();
+ }
+
+ clear(event) {
+ if (
+ event.type == "click" ||
+ (event.type == "keydown" && event.code == "Enter") ||
+ (event.type == "keydown" && event.code == "Space")
+ ) {
+ this.#query = "";
+ event.preventDefault();
+ this.onSearch();
+ }
+ }
+
+ #dispatchQueryEvent() {
+ window.scrollTo(0, 0);
+ this.dispatchEvent(
+ new CustomEvent("fxview-search-textbox-query", {
+ bubbles: true,
+ composed: true,
+ detail: { query: this.#query },
+ })
+ );
+
+ Services.telemetry.recordEvent(
+ "firefoxview_next",
+ "search_initiated",
+ "search",
+ null,
+ {
+ page: this.pageName,
+ }
+ );
+ }
+
+ render() {
+ return html`
+ <link rel="stylesheet" href="chrome://browser/content/firefoxview/fxview-search-textbox.css" />
+ <div class="search-container">
+ <div class="search-icon"></div>
+ <input
+ type="search"
+ .placeholder=${ifDefined(this.placeholder)}
+ .size=${ifDefined(this.size)}
+ .value=${this.#query}
+ @input=${this.onInput}
+ ></input>
+ <div
+ class="clear-icon"
+ role="button"
+ tabindex="0"
+ ?hidden=${!this.#query}
+ @click=${this.clear}
+ @keydown=${this.clear}
+ data-l10n-id="firefoxview-search-text-box-clear-button"
+ ></div>
+ </div>`;
+ }
+}
+
+customElements.define("fxview-search-textbox", FxviewSearchTextbox);
diff --git a/browser/components/firefoxview/fxview-tab-list.css b/browser/components/firefoxview/fxview-tab-list.css
new file mode 100644
index 0000000000..d32d9c9c08
--- /dev/null
+++ b/browser/components/firefoxview/fxview-tab-list.css
@@ -0,0 +1,24 @@
+/* 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 min-content 2fr 1fr 1fr min-content min-content;
+ gap: 6px;
+}
+
+:host([compactRows]) .fxview-tab-list {
+ grid-template-columns: min-content 1fr min-content min-content min-content;
+}
+
+virtual-list {
+ display: grid;
+ grid-column: span 9;
+ grid-template-columns: subgrid;
+
+ .top-padding,
+ .bottom-padding {
+ grid-column: span 9;
+ }
+}
diff --git a/browser/components/firefoxview/fxview-tab-list.mjs b/browser/components/firefoxview/fxview-tab-list.mjs
new file mode 100644
index 0000000000..055540722a
--- /dev/null
+++ b/browser/components/firefoxview/fxview-tab-list.mjs
@@ -0,0 +1,919 @@
+/* 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 {
+ classMap,
+ html,
+ ifDefined,
+ repeat,
+ styleMap,
+ when,
+} from "chrome://global/content/vendor/lit.all.mjs";
+import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
+import { escapeRegExp } from "./helpers.mjs";
+
+const NOW_THRESHOLD_MS = 91000;
+const FXVIEW_ROW_HEIGHT_PX = 32;
+const lazy = {};
+let XPCOMUtils;
+
+if (!window.IS_STORYBOOK) {
+ XPCOMUtils = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+ ).XPCOMUtils;
+ XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "virtualListEnabledPref",
+ "browser.firefox-view.virtual-list.enabled"
+ );
+ ChromeUtils.defineLazyGetter(lazy, "relativeTimeFormat", () => {
+ return new Services.intl.RelativeTimeFormat(undefined, {
+ style: "narrow",
+ });
+ });
+
+ ChromeUtils.defineESModuleGetters(lazy, {
+ BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
+ });
+}
+
+/**
+ * A list of clickable tab items
+ *
+ * @property {boolean} compactRows - Whether to hide the URL and date/time for each tab.
+ * @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
+ * @property {string} searchQuery - The query string to highlight, if provided.
+ */
+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.compactRows = false;
+ this.updatesPaused = true;
+ this.#register();
+ }
+
+ static properties = {
+ activeIndex: { type: Number },
+ compactRows: { type: Boolean },
+ currentActiveElementId: { type: String },
+ dateTimeFormat: { type: String },
+ hasPopup: { type: String },
+ maxTabsLength: { type: Number },
+ tabItems: { type: Array },
+ updatesPaused: { type: Boolean },
+ searchQuery: { type: String },
+ };
+
+ static queries = {
+ rowEls: { all: "fxview-tab-row" },
+ rootVirtualListEl: "virtual-list",
+ };
+
+ willUpdate(changes) {
+ this.activeIndex = Math.min(
+ Math.max(this.activeIndex, 0),
+ this.tabItems.length - 1
+ );
+
+ if (changes.has("dateTimeFormat") || changes.has("updatesPaused")) {
+ this.clearIntervalTimer();
+ if (
+ !this.updatesPaused &&
+ this.dateTimeFormat == "relative" &&
+ !window.IS_STORYBOOK
+ ) {
+ this.startIntervalTimer();
+ this.onIntervalUpdate();
+ }
+ }
+
+ if (this.maxTabsLength > 0) {
+ // Can set maxTabsLength to -1 to have no max
+ this.tabItems = this.tabItems.slice(0, this.maxTabsLength);
+ }
+ }
+
+ startIntervalTimer() {
+ this.clearIntervalTimer();
+ this.intervalID = setInterval(
+ () => this.onIntervalUpdate(),
+ this.timeMsPref
+ );
+ }
+
+ clearIntervalTimer() {
+ if (this.intervalID) {
+ clearInterval(this.intervalID);
+ delete this.intervalID;
+ }
+ }
+
+ #register() {
+ if (!window.IS_STORYBOOK) {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "timeMsPref",
+ "browser.tabs.firefox-view.updateTimeMs",
+ NOW_THRESHOLD_MS,
+ (prefName, oldVal, newVal) => {
+ this.clearIntervalTimer();
+ if (!this.isConnected) {
+ return;
+ }
+ this.startIntervalTimer();
+ this.requestUpdate();
+ }
+ );
+ }
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ if (
+ !this.updatesPaused &&
+ this.dateTimeFormat === "relative" &&
+ !window.IS_STORYBOOK
+ ) {
+ this.startIntervalTimer();
+ }
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this.clearIntervalTimer();
+ }
+
+ 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") {
+ if (
+ (fxviewTabRow.soundPlaying || fxviewTabRow.muted) &&
+ this.currentActiveElementId === "fxview-tab-row-secondary-button"
+ ) {
+ this.currentActiveElementId = fxviewTabRow.focusMediaButton();
+ } else {
+ this.currentActiveElementId = fxviewTabRow.focusLink();
+ }
+ } else if (
+ (fxviewTabRow.soundPlaying || fxviewTabRow.muted) &&
+ this.currentActiveElementId === "fxview-tab-row-main"
+ ) {
+ this.currentActiveElementId = fxviewTabRow.focusMediaButton();
+ } 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") {
+ if (
+ (fxviewTabRow.soundPlaying || fxviewTabRow.muted) &&
+ this.currentActiveElementId === "fxview-tab-row-main"
+ ) {
+ this.currentActiveElementId = fxviewTabRow.focusMediaButton();
+ } else {
+ this.currentActiveElementId = fxviewTabRow.focusButton();
+ }
+ } else if (
+ (fxviewTabRow.soundPlaying || fxviewTabRow.muted) &&
+ this.currentActiveElementId === "fxview-tab-row-secondary-button"
+ ) {
+ this.currentActiveElementId = fxviewTabRow.focusMediaButton();
+ } else {
+ this.currentActiveElementId = fxviewTabRow.focusLink();
+ }
+ }
+ }
+
+ focusPrevRow() {
+ this.focusIndex(this.activeIndex - 1);
+ }
+
+ focusNextRow() {
+ this.focusIndex(this.activeIndex + 1);
+ }
+
+ async focusIndex(index) {
+ // Focus link or button of item
+ if (lazy.virtualListEnabledPref) {
+ let row = this.rootVirtualListEl.getItem(index);
+ if (!row) {
+ return;
+ }
+ let subList = this.rootVirtualListEl.getSubListForItem(index);
+ if (!subList) {
+ return;
+ }
+ this.activeIndex = index;
+
+ // In Bug 1866845, these manual updates to the sublists should be removed
+ // and scrollIntoView() should also be iterated on so that we aren't constantly
+ // moving the focused item to the center of the viewport
+ for (const sublist of Array.from(this.rootVirtualListEl.children)) {
+ await sublist.requestUpdate();
+ await sublist.updateComplete;
+ }
+ row.scrollIntoView({ block: "center" });
+ row.focus();
+ } else if (index >= 0 && index < this.rowEls?.length) {
+ this.rowEls[index].focus();
+ this.activeIndex = index;
+ }
+ }
+
+ shouldUpdate(changes) {
+ if (changes.has("updatesPaused")) {
+ if (this.updatesPaused) {
+ this.clearIntervalTimer();
+ }
+ }
+ return !this.updatesPaused;
+ }
+
+ itemTemplate = (tabItem, i) => {
+ let time;
+ if (tabItem.time || tabItem.closedAt) {
+ let stringTime = (tabItem.time || tabItem.closedAt).toString();
+ // Different APIs return time in different units, so we use
+ // the length to decide if it's milliseconds or nanoseconds.
+ if (stringTime.length === 16) {
+ time = (tabItem.time || tabItem.closedAt) / 1000;
+ } else {
+ time = tabItem.time || tabItem.closedAt;
+ }
+ }
+ return html`
+ <fxview-tab-row
+ exportparts="secondary-button"
+ ?active=${i == this.activeIndex}
+ ?compact=${this.compactRows}
+ .hasPopup=${this.hasPopup}
+ .containerObj=${tabItem.containerObj}
+ .currentActiveElementId=${this.currentActiveElementId}
+ .dateTimeFormat=${this.dateTimeFormat}
+ .favicon=${tabItem.icon}
+ .isBookmark=${ifDefined(tabItem.isBookmark)}
+ .muted=${ifDefined(tabItem.muted)}
+ .pinned=${ifDefined(tabItem.pinned)}
+ .primaryL10nId=${tabItem.primaryL10nId}
+ .primaryL10nArgs=${ifDefined(tabItem.primaryL10nArgs)}
+ role="listitem"
+ .secondaryL10nId=${tabItem.secondaryL10nId}
+ .secondaryL10nArgs=${ifDefined(tabItem.secondaryL10nArgs)}
+ .attention=${ifDefined(tabItem.attention)}
+ .soundPlaying=${ifDefined(tabItem.soundPlaying)}
+ .sourceClosedId=${ifDefined(tabItem.sourceClosedId)}
+ .sourceWindowId=${ifDefined(tabItem.sourceWindowId)}
+ .closedId=${ifDefined(tabItem.closedId || tabItem.closedId)}
+ .searchQuery=${ifDefined(this.searchQuery)}
+ .tabElement=${ifDefined(tabItem.tabElement)}
+ .time=${ifDefined(time)}
+ .timeMsPref=${ifDefined(this.timeMsPref)}
+ .title=${tabItem.title}
+ .titleChanged=${ifDefined(tabItem.titleChanged)}
+ .url=${tabItem.url}
+ ></fxview-tab-row>
+ `;
+ };
+
+ render() {
+ if (this.searchQuery && this.tabItems.length === 0) {
+ return this.#emptySearchResultsTemplate();
+ }
+ return html`
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/firefoxview/fxview-tab-list.css"
+ />
+ <div
+ id="fxview-tab-list"
+ class="fxview-tab-list"
+ role="list"
+ @keydown=${this.handleFocusElementInRow}
+ >
+ ${when(
+ lazy.virtualListEnabledPref,
+ () => html`
+ <virtual-list
+ .activeIndex=${this.activeIndex}
+ .items=${this.tabItems}
+ .template=${this.itemTemplate}
+ ></virtual-list>
+ `
+ )}
+ ${when(
+ !lazy.virtualListEnabledPref,
+ () => html`
+ ${this.tabItems.map((tabItem, i) => this.itemTemplate(tabItem, i))}
+ `
+ )}
+ </div>
+ <slot name="menu"></slot>
+ `;
+ }
+
+ #emptySearchResultsTemplate() {
+ return html` <fxview-empty-state
+ class="search-results"
+ headerLabel="firefoxview-search-results-empty"
+ .headerArgs=${{ query: this.searchQuery }}
+ isInnerCard
+ >
+ </fxview-empty-state>`;
+ }
+}
+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 {boolean} compact - Whether to hide the URL and date/time for this tab.
+ * @property {object} containerObj - Info about an open tab's container if within one
+ * @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 {boolean} isBookmark - Whether an open tab is bookmarked
+ * @property {number} closedId - The tab ID for when the tab item was closed.
+ * @property {number} sourceClosedId - The closedId of the closed window its from if applicable
+ * @property {number} sourceWindowId - The sessionstore id of the window its from if applicable
+ * @property {string} favicon - The favicon for the tab item.
+ * @property {boolean} muted - Whether an open tab is muted
+ * @property {boolean} pinned - Whether an open tab is pinned
+ * @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 {boolean} attention - Whether to show a notification dot
+ * @property {boolean} soundPlaying - Whether an open tab has soundPlaying
+ * @property {object} tabElement - The MozTabbrowserTab element for the tab item.
+ * @property {number} time - The timestamp for when the tab was last accessed.
+ * @property {string} title - The title for the tab item.
+ * @property {boolean} titleChanged - Whether the title has changed for an open tab
+ * @property {string} url - The url for the tab item.
+ * @property {number} timeMsPref - The frequency in milliseconds of updates to relative time
+ * @property {string} searchQuery - The query string to highlight, if provided.
+ */
+export class FxviewTabRow extends MozLitElement {
+ constructor() {
+ super();
+ this.active = false;
+ this.currentActiveElementId = "fxview-tab-row-main";
+ }
+
+ static properties = {
+ active: { type: Boolean },
+ compact: { type: Boolean },
+ containerObj: { type: Object },
+ currentActiveElementId: { type: String },
+ dateTimeFormat: { type: String },
+ favicon: { type: String },
+ hasPopup: { type: String },
+ isBookmark: { type: Boolean },
+ muted: { type: Boolean },
+ pinned: { type: Boolean },
+ primaryL10nId: { type: String },
+ primaryL10nArgs: { type: String },
+ secondaryL10nId: { type: String },
+ secondaryL10nArgs: { type: String },
+ soundPlaying: { type: Boolean },
+ closedId: { type: Number },
+ sourceClosedId: { type: Number },
+ sourceWindowId: { type: String },
+ tabElement: { type: Object },
+ time: { type: Number },
+ title: { type: String },
+ titleChanged: { type: Boolean },
+ attention: { type: Boolean },
+ timeMsPref: { type: Number },
+ url: { type: String },
+ searchQuery: { type: String },
+ };
+
+ static queries = {
+ mainEl: ".fxview-tab-row-main",
+ buttonEl: "#fxview-tab-row-secondary-button:not([hidden])",
+ mediaButtonEl: "#fxview-tab-row-media-button",
+ };
+
+ get currentFocusable() {
+ let focusItem = this.renderRoot.getElementById(this.currentActiveElementId);
+ if (!focusItem) {
+ focusItem = this.renderRoot.getElementById("fxview-tab-row-main");
+ }
+ return focusItem;
+ }
+
+ focus() {
+ this.currentFocusable.focus();
+ }
+
+ focusButton() {
+ this.buttonEl.focus();
+ return this.buttonEl.id;
+ }
+
+ focusMediaButton() {
+ this.mediaButtonEl.focus();
+ return this.mediaButtonEl.id;
+ }
+
+ focusLink() {
+ this.mainEl.focus();
+ return this.mainEl.id;
+ }
+
+ 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 null;
+ }
+ 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 `chrome://global/skin/icons/defaultFavicon.svg`;
+ }
+ if (!icon) {
+ if (targetURI?.startsWith("moz-extension")) {
+ return "chrome://mozapps/skin/extensions/extension.svg";
+ }
+ return `chrome://global/skin/icons/defaultFavicon.svg`;
+ }
+ // If the icon is not for website (doesn't begin with http), we
+ // display it directly. Otherwise we go through the page-icon
+ // protocol to try to get a cached version. We don't load
+ // favicons directly.
+ if (icon.startsWith("http")) {
+ return `page-icon:${targetURI}`;
+ }
+ return icon;
+ }
+
+ getContainerClasses() {
+ let containerClasses = ["fxview-tab-row-container-indicator", "icon"];
+ if (this.containerObj) {
+ let { icon, color } = this.containerObj;
+ containerClasses.push(`identity-icon-${icon}`);
+ containerClasses.push(`identity-color-${color}`);
+ }
+ return containerClasses;
+ }
+
+ 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 },
+ })
+ );
+ }
+ }
+
+ muteOrUnmuteTab() {
+ this.tabElement.toggleMuteAudio();
+ this.muted = !this.muted;
+ }
+
+ 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`
+ ${when(
+ this.containerObj,
+ () => html`
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/usercontext/usercontext.css"
+ />
+ `
+ )}
+ <link
+ rel="stylesheet"
+ href="chrome://global/skin/in-content/common.css"
+ />
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/firefoxview/fxview-tab-row.css"
+ />
+ <a
+ href=${ifDefined(this.url)}
+ class="fxview-tab-row-main"
+ id="fxview-tab-row-main"
+ tabindex=${this.active &&
+ this.currentActiveElementId === "fxview-tab-row-main"
+ ? "0"
+ : "-1"}
+ data-l10n-id=${ifDefined(this.primaryL10nId)}
+ data-l10n-args=${ifDefined(this.primaryL10nArgs)}
+ @click=${this.primaryActionHandler}
+ @keydown=${this.primaryActionHandler}
+ >
+ <span
+ class="${classMap({
+ "fxview-tab-row-favicon-wrapper": true,
+ bookmark: this.isBookmark && !this.attention,
+ notification: this.pinned
+ ? this.attention || this.titleChanged
+ : this.attention,
+ soundplaying: this.soundPlaying && !this.muted && this.pinned,
+ muted: this.muted && this.pinned,
+ })}"
+ >
+ <span
+ class="fxview-tab-row-favicon icon"
+ id="fxview-tab-row-favicon"
+ style=${styleMap({
+ backgroundImage: `url(${this.getImageUrl(
+ this.favicon,
+ this.url
+ )})`,
+ })}
+ ></span>
+ </span>
+ <span
+ class="fxview-tab-row-title text-truncated-ellipsis"
+ id="fxview-tab-row-title"
+ dir="auto"
+ >
+ ${when(
+ this.searchQuery,
+ () => this.#highlightSearchMatches(this.searchQuery, title),
+ () => title
+ )}
+ </span>
+ <span class=${this.getContainerClasses().join(" ")}></span>
+ <span
+ class="fxview-tab-row-url text-truncated-ellipsis"
+ id="fxview-tab-row-url"
+ ?hidden=${this.compact}
+ >
+ ${when(
+ this.searchQuery,
+ () =>
+ this.#highlightSearchMatches(
+ this.searchQuery,
+ this.formatURIForDisplay(this.url)
+ ),
+ () => this.formatURIForDisplay(this.url)
+ )}
+ </span>
+ <span
+ class="fxview-tab-row-date"
+ id="fxview-tab-row-date"
+ ?hidden=${this.compact}
+ >
+ <span
+ ?hidden=${relativeString || !dateString}
+ data-l10n-id=${ifDefined(dateString)}
+ data-l10n-args=${ifDefined(dateArgs)}
+ ></span>
+ <span ?hidden=${!relativeString}>${relativeString}</span>
+ </span>
+ <span
+ class="fxview-tab-row-time"
+ id="fxview-tab-row-time"
+ ?hidden=${this.compact || !timeString}
+ data-timestamp=${ifDefined(this.time)}
+ data-l10n-id=${ifDefined(timeString)}
+ data-l10n-args=${ifDefined(timeArgs)}
+ >
+ </span>
+ </a>
+ ${when(
+ (this.soundPlaying || this.muted) && !this.pinned,
+ () => html`<button
+ class=fxview-tab-row-button ghost-button icon-button semi-transparent"
+ id="fxview-tab-row-media-button"
+ data-l10n-id=${
+ this.muted
+ ? "fxviewtabrow-unmute-tab-button"
+ : "fxviewtabrow-mute-tab-button"
+ }
+ data-l10n-args=${JSON.stringify({ tabTitle: title })}
+ muted=${ifDefined(this.muted)}
+ soundplaying=${this.soundPlaying && !this.muted}
+ @click=${this.muteOrUnmuteTab}
+ tabindex="${
+ this.active &&
+ this.currentActiveElementId === "fxview-tab-row-media-button"
+ ? "0"
+ : "-1"
+ }"
+ ></button>`,
+ () => html`<span></span>`
+ )}
+ ${when(
+ this.secondaryL10nId && this.secondaryActionHandler,
+ () => html`<button
+ class="fxview-tab-row-button ghost-button icon-button semi-transparent"
+ id="fxview-tab-row-secondary-button"
+ part="secondary-button"
+ data-l10n-id=${this.secondaryL10nId}
+ data-l10n-args=${ifDefined(this.secondaryL10nArgs)}
+ aria-haspopup=${ifDefined(this.hasPopup)}
+ @click=${this.secondaryActionHandler}
+ tabindex="${this.active &&
+ this.currentActiveElementId === "fxview-tab-row-secondary-button"
+ ? "0"
+ : "-1"}"
+ ></button>`
+ )}
+ `;
+ }
+
+ /**
+ * Find all matches of query within the given string, and compute the result
+ * to be rendered.
+ *
+ * @param {string} query
+ * @param {string} string
+ */
+ #highlightSearchMatches(query, string) {
+ const fragments = [];
+ const regex = RegExp(escapeRegExp(query), "dgi");
+ let prevIndexEnd = 0;
+ let result;
+ while ((result = regex.exec(string)) !== null) {
+ const [indexStart, indexEnd] = result.indices[0];
+ fragments.push(string.substring(prevIndexEnd, indexStart));
+ fragments.push(
+ html`<strong>${string.substring(indexStart, indexEnd)}</strong>`
+ );
+ prevIndexEnd = regex.lastIndex;
+ }
+ fragments.push(string.substring(prevIndexEnd));
+ return fragments;
+ }
+}
+
+customElements.define("fxview-tab-row", FxviewTabRow);
+
+export class VirtualList extends MozLitElement {
+ static properties = {
+ items: { type: Array },
+ template: { type: Function },
+ activeIndex: { type: Number },
+ itemOffset: { type: Number },
+ maxRenderCountEstimate: { type: Number, state: true },
+ itemHeightEstimate: { type: Number, state: true },
+ isAlwaysVisible: { type: Boolean },
+ isVisible: { type: Boolean, state: true },
+ isSubList: { type: Boolean },
+ };
+
+ createRenderRoot() {
+ return this;
+ }
+
+ constructor() {
+ super();
+ this.activeIndex = 0;
+ this.itemOffset = 0;
+ this.items = [];
+ this.subListItems = [];
+ this.itemHeightEstimate = FXVIEW_ROW_HEIGHT_PX;
+ this.maxRenderCountEstimate = Math.max(
+ 40,
+ 2 * Math.ceil(window.innerHeight / this.itemHeightEstimate)
+ );
+ this.isSubList = false;
+ this.isVisible = false;
+ this.intersectionObserver = new IntersectionObserver(
+ ([entry]) => (this.isVisible = entry.isIntersecting),
+ { root: this.ownerDocument }
+ );
+ this.resizeObserver = new ResizeObserver(([entry]) => {
+ if (entry.contentRect?.height > 0) {
+ // Update properties on top-level virtual-list
+ this.parentElement.itemHeightEstimate = entry.contentRect.height;
+ this.parentElement.maxRenderCountEstimate = Math.max(
+ 40,
+ 2 * Math.ceil(window.innerHeight / this.itemHeightEstimate)
+ );
+ }
+ });
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this.intersectionObserver.disconnect();
+ this.resizeObserver.disconnect();
+ }
+
+ triggerIntersectionObserver() {
+ this.intersectionObserver.unobserve(this);
+ this.intersectionObserver.observe(this);
+ }
+
+ getSubListForItem(index) {
+ if (this.isSubList) {
+ throw new Error("Cannot get sublist for item");
+ }
+ return this.children[parseInt(index / this.maxRenderCountEstimate, 10)];
+ }
+
+ getItem(index) {
+ if (!this.isSubList) {
+ return this.getSubListForItem(index)?.getItem(
+ index % this.maxRenderCountEstimate
+ );
+ }
+ return this.children[index];
+ }
+
+ willUpdate(changedProperties) {
+ if (changedProperties.has("items") && !this.isSubList) {
+ this.subListItems = [];
+ for (let i = 0; i < this.items.length; i += this.maxRenderCountEstimate) {
+ this.subListItems.push(
+ this.items.slice(i, i + this.maxRenderCountEstimate)
+ );
+ }
+ this.triggerIntersectionObserver();
+ }
+ }
+
+ recalculateAfterWindowResize() {
+ this.maxRenderCountEstimate = Math.max(
+ 40,
+ 2 * Math.ceil(window.innerHeight / this.itemHeightEstimate)
+ );
+ }
+
+ firstUpdated() {
+ this.intersectionObserver.observe(this);
+ if (this.isSubList && this.children[0]) {
+ this.resizeObserver.observe(this.children[0]);
+ }
+ }
+
+ updated(changedProperties) {
+ this.updateListHeight(changedProperties);
+ }
+
+ updateListHeight(changedProperties) {
+ if (
+ changedProperties.has("isAlwaysVisible") ||
+ changedProperties.has("isVisible")
+ ) {
+ this.style.height =
+ this.isAlwaysVisible || this.isVisible
+ ? "auto"
+ : `${this.items.length * this.itemHeightEstimate}px`;
+ }
+ }
+
+ get renderItems() {
+ return this.isSubList ? this.items : this.subListItems;
+ }
+
+ subListTemplate = (data, i) => {
+ return html`<virtual-list
+ .template=${this.template}
+ .items=${data}
+ .itemHeightEstimate=${this.itemHeightEstimate}
+ .itemOffset=${i * this.maxRenderCountEstimate}
+ .isAlwaysVisible=${i ==
+ parseInt(this.activeIndex / this.maxRenderCountEstimate, 10)}
+ isSubList
+ ></virtual-list>`;
+ };
+
+ itemTemplate = (data, i) => this.template(data, this.itemOffset + i);
+
+ render() {
+ if (this.isAlwaysVisible || this.isVisible) {
+ return html`
+ ${repeat(
+ this.renderItems,
+ (data, i) => i,
+ this.isSubList ? this.itemTemplate : this.subListTemplate
+ )}
+ `;
+ }
+ return "";
+ }
+}
+
+customElements.define("virtual-list", VirtualList);
diff --git a/browser/components/firefoxview/fxview-tab-row.css b/browser/components/firefoxview/fxview-tab-row.css
new file mode 100644
index 0000000000..ceb059a33b
--- /dev/null
+++ b/browser/components/firefoxview/fxview-tab-row.css
@@ -0,0 +1,204 @@
+/* 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 9;
+ 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 6;
+ 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-wrapper {
+ height: 16px;
+
+ .fxview-tab-row-favicon::after {
+ display: block;
+ content: "";
+ background-size: 12px;
+ background-position: center;
+ background-repeat: no-repeat;
+ position: absolute;
+ height: 12px;
+ width: 12px;
+ -moz-context-properties: fill, stroke;
+ fill: currentColor;
+ stroke: var(--fxview-background-color-secondary);
+ }
+
+ &.bookmark .fxview-tab-row-favicon::after {
+ background-image: url("chrome://browser/skin/bookmark-12.svg");
+ inset-block-start: 9px;
+ inset-inline-end: -6px;
+ fill: var(--fxview-primary-action-background);
+ }
+
+ &.notification .fxview-tab-row-favicon::after {
+ background-image: radial-gradient(circle, light-dark(rgb(42, 195, 162), rgb(84, 255, 189)), light-dark(rgb(42, 195, 162), rgb(84, 255, 189)) 2px, transparent 2px);
+ height: 4px;
+ width: 100%;
+ inset-block-start: 20px;
+ }
+
+ &.soundplaying .fxview-tab-row-favicon::after {
+ background-image: url("chrome://global/skin/media/audio.svg");
+ inset-block-start: -5px;
+ inset-inline-end: -7px;
+ border-radius: 100%;
+ background-color: var(--fxview-background-color-secondary);
+ padding: 2px;
+ }
+
+ &.muted .fxview-tab-row-favicon::after {
+ background-image: url("chrome://global/skin/media/audio-muted.svg");
+ inset-block-start: -5px;
+ inset-inline-end: -7px;
+ border-radius: 100%;
+ background-color: var(--fxview-background-color-secondary);
+ padding: 2px;
+ }
+}
+
+.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-align: match-parent;
+}
+
+.fxview-tab-row-container-indicator {
+ height: 16px;
+ width: 16px;
+ background-image: var(--identity-icon);
+ background-size: cover;
+ -moz-context-properties: fill;
+ fill: var(--identity-icon-color);
+}
+
+.fxview-tab-row-url {
+ color: var(--text-color-deemphasized);
+ text-decoration-line: underline;
+ direction: ltr;
+ text-align: match-parent;
+}
+
+.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;
+ min-width: 0;
+ background-color: transparent;
+
+ &[muted="true"],
+ &[soundplaying="true"] {
+ background-size: 16px;
+ background-repeat: no-repeat;
+ background-position: center;
+ -moz-context-properties: fill;
+ fill: currentColor;
+ }
+
+ &[muted="true"] {
+ background-image: url("chrome://global/skin/media/audio-muted.svg");
+ }
+
+ &[soundplaying="true"] {
+ background-image: url("chrome://global/skin/media/audio.svg");
+ }
+}
+
+@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..3cb308a587
--- /dev/null
+++ b/browser/components/firefoxview/helpers.mjs
@@ -0,0 +1,175 @@
+/* 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 = {};
+const loggersByName = new Map();
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
+ Log: "resource://gre/modules/Log.sys.mjs",
+ PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "relativeTimeFormat", () => {
+ return new Services.intl.RelativeTimeFormat(undefined, { style: "narrow" });
+});
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "searchEnabledPref",
+ "browser.firefox-view.search.enabled"
+);
+
+// Cutoff of 1.5 minutes + 1 second to determine what text string to display
+export const NOW_THRESHOLD_MS = 91000;
+
+// Configure logging level via this pref
+export const LOGGING_PREF = "browser.tabs.firefox-view.logLevel";
+
+export const MAX_TABS_FOR_RECENT_BROWSING = 5;
+
+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}`;
+}
+
+/**
+ * This function doesn't just copy the link to the clipboard, it creates a
+ * URL object on the clipboard, so when it's pasted into an application that
+ * supports it, it displays the title as a link.
+ */
+export function placeLinkOnClipboard(title, uri) {
+ let node = {
+ type: 0,
+ title,
+ uri,
+ };
+
+ // Copied from doCommand/placesCmd_copy in PlacesUIUtils.sys.mjs
+
+ // This is a little hacky, but there is a lot of code in Places that handles
+ // clipboard stuff, so it's easier to reuse.
+
+ // This order is _important_! It controls how this and other applications
+ // select data to be inserted based on type.
+ let contents = [
+ { type: lazy.PlacesUtils.TYPE_X_MOZ_URL, entries: [] },
+ { type: lazy.PlacesUtils.TYPE_HTML, entries: [] },
+ { type: lazy.PlacesUtils.TYPE_PLAINTEXT, entries: [] },
+ ];
+
+ contents.forEach(function (content) {
+ content.entries.push(lazy.PlacesUtils.wrapNode(node, content.type));
+ });
+
+ let xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+ Ci.nsITransferable
+ );
+ xferable.init(null);
+
+ function addData(type, data) {
+ xferable.addDataFlavor(type);
+ xferable.setTransferData(type, lazy.PlacesUtils.toISupportsString(data));
+ }
+
+ contents.forEach(function (content) {
+ addData(content.type, content.entries.join(lazy.PlacesUtils.endl));
+ });
+
+ Services.clipboard.setData(xferable, null, Ci.nsIClipboard.kGlobalClipboard);
+}
+
+/**
+ * Check the user preference to enable search functionality in Firefox View.
+ *
+ * @returns {boolean} The preference value.
+ */
+export function isSearchEnabled() {
+ return lazy.searchEnabledPref;
+}
+
+/**
+ * Escape special characters for regular expressions from a string.
+ *
+ * @param {string} string
+ * The string to sanitize.
+ * @returns {string} The sanitized string.
+ */
+export function escapeRegExp(string) {
+ return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+/**
+ * Search a tab list for items that match the given query.
+ */
+export function searchTabList(query, tabList) {
+ const regex = RegExp(escapeRegExp(query), "i");
+ return tabList.filter(
+ ({ title, url }) => regex.test(title) || regex.test(url)
+ );
+}
+
+/**
+ * Get or create a logger, whose log-level is controlled by a pref
+ *
+ * @param {string} loggerName - Creating named loggers helps differentiate log messages from different
+ components or features.
+ */
+
+export function getLogger(loggerName) {
+ if (!loggersByName.has(loggerName)) {
+ let logger = lazy.Log.repository.getLogger(`FirefoxView.${loggerName}`);
+ logger.manageLevelFromPref(LOGGING_PREF);
+ logger.addAppender(
+ new lazy.Log.ConsoleAppender(new lazy.Log.BasicFormatter())
+ );
+ loggersByName.set(loggerName, logger);
+ }
+ return loggersByName.get(loggerName);
+}
+
+export function escapeHtmlEntities(text) {
+ return (text || "")
+ .replace(/&/g, "&amp;")
+ .replace(/</g, "&lt;")
+ .replace(/>/g, "&gt;")
+ .replace(/"/g, "&quot;")
+ .replace(/'/g, "&#39;");
+}
diff --git a/browser/components/firefoxview/history.css b/browser/components/firefoxview/history.css
new file mode 100644
index 0000000000..dd2786a8c7
--- /dev/null
+++ b/browser/components/firefoxview/history.css
@@ -0,0 +1,80 @@
+/* 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/. */
+
+.history-sort-options:not([hidden]) {
+ display: flex;
+ gap: 24px;
+}
+
+.history-sort-option {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+
+ & label {
+ white-space: nowrap;
+ }
+}
+
+.show-all-history-footer {
+ text-align: center;
+ margin-block-end: 24px;
+}
+
+.import-history-banner .banner-text {
+ display: flex;
+ flex-direction: column;
+ font-size: 0.95rem;
+ gap: 6px;
+}
+
+.import-history-banner .banner-text span:first-child {
+ font-weight: 600;
+}
+
+.import-history-banner [slot="main"] {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 16px;
+ padding: 8px;
+}
+
+.import-history-banner .buttons {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.choose-browser {
+ font-size: 0.87em;
+ cursor: pointer;
+}
+
+.import-history-banner .close {
+ background-image: url("chrome://global/skin/icons/close-12.svg");
+ background-repeat: no-repeat;
+ background-position: center center;
+ -moz-context-properties: fill;
+ fill: currentColor;
+ min-width: auto;
+ min-height: auto;
+ width: 24px;
+ height: 24px;
+ margin: 0;
+ padding: 0;
+ flex-shrink: 0;
+}
+
+dialog {
+ border: 1px solid transparent;
+ border-radius: 8px;
+ box-shadow: 0 2px 6px 0 rgba(0,0,0,0.3);
+ padding: 24px;
+}
+
+@media (prefers-color-scheme: dark) {
+ dialog {
+ --in-content-page-background: #42414d;
+ }
+}
diff --git a/browser/components/firefoxview/history.mjs b/browser/components/firefoxview/history.mjs
new file mode 100644
index 0000000000..935cc037e9
--- /dev/null
+++ b/browser/components/firefoxview/history.mjs
@@ -0,0 +1,656 @@
+/* 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,
+ when,
+} from "chrome://global/content/vendor/lit.all.mjs";
+import { escapeHtmlEntities, isSearchEnabled } from "./helpers.mjs";
+import { ViewPage } from "./viewpage.mjs";
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://browser/content/migration/migration-wizard.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
+ FirefoxViewPlacesQuery:
+ "resource:///modules/firefox-view-places-query.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+ ProfileAge: "resource://gre/modules/ProfileAge.sys.mjs",
+});
+
+let XPCOMUtils = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+).XPCOMUtils;
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "maxRowsPref",
+ "browser.firefox-view.max-history-rows",
+ -1
+);
+
+const NEVER_REMEMBER_HISTORY_PREF = "browser.privatebrowsing.autostart";
+const HAS_IMPORTED_HISTORY_PREF = "browser.migrate.interactions.history";
+const IMPORT_HISTORY_DISMISSED_PREF =
+ "browser.tabs.firefox-view.importHistory.dismissed";
+
+const SEARCH_RESULTS_LIMIT = 300;
+
+class HistoryInView extends ViewPage {
+ constructor() {
+ super();
+ this._started = false;
+ this.allHistoryItems = new Map();
+ this.historyMapByDate = [];
+ this.historyMapBySite = [];
+ // Setting maxTabsLength to -1 for no max
+ this.maxTabsLength = -1;
+ this.placesQuery = new lazy.FirefoxViewPlacesQuery();
+ this.searchQuery = "";
+ this.searchResults = null;
+ this.sortOption = "date";
+ this.profileAge = 8;
+ this.fullyUpdated = false;
+ this.cumulativeSearches = 0;
+ }
+
+ start() {
+ if (this._started) {
+ return;
+ }
+ this._started = true;
+
+ this.#updateAllHistoryItems();
+ this.placesQuery.observeHistory(data => this.#updateAllHistoryItems(data));
+
+ this.toggleVisibilityInCardContainer();
+ }
+
+ async connectedCallback() {
+ super.connectedCallback();
+ await this.updateHistoryData();
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "importHistoryDismissedPref",
+ IMPORT_HISTORY_DISMISSED_PREF,
+ false,
+ () => {
+ this.requestUpdate();
+ }
+ );
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "hasImportedHistoryPref",
+ HAS_IMPORTED_HISTORY_PREF,
+ false,
+ () => {
+ this.requestUpdate();
+ }
+ );
+ if (!this.importHistoryDismissedPref && !this.hasImportedHistoryPrefs) {
+ let profileAccessor = await lazy.ProfileAge();
+ let profileCreateTime = await profileAccessor.created;
+ let timeNow = new Date().getTime();
+ let profileAge = timeNow - profileCreateTime;
+ // Convert milliseconds to days
+ this.profileAge = profileAge / 1000 / 60 / 60 / 24;
+ }
+ }
+
+ stop() {
+ if (!this._started) {
+ return;
+ }
+ this._started = false;
+ this.placesQuery.close();
+
+ this.toggleVisibilityInCardContainer();
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this.stop();
+ this.migrationWizardDialog?.removeEventListener(
+ "MigrationWizard:Close",
+ this.migrationWizardDialog
+ );
+ }
+
+ async #updateAllHistoryItems(allHistoryItems) {
+ if (allHistoryItems) {
+ this.allHistoryItems = allHistoryItems;
+ } else {
+ await this.updateHistoryData();
+ }
+ this.resetHistoryMaps();
+ this.lists.forEach(list => list.requestUpdate());
+ await this.#updateSearchResults();
+ }
+
+ async #updateSearchResults() {
+ if (this.searchQuery) {
+ try {
+ this.searchResults = await this.placesQuery.searchHistory(
+ this.searchQuery,
+ SEARCH_RESULTS_LIMIT
+ );
+ } catch (e) {
+ // Connection interrupted, ignore.
+ }
+ } else {
+ this.searchResults = null;
+ }
+ }
+
+ viewVisibleCallback() {
+ this.start();
+ }
+
+ viewHiddenCallback() {
+ this.stop();
+ }
+
+ static queries = {
+ cards: { all: "card-container:not([hidden])" },
+ migrationWizardDialog: "#migrationWizardDialog",
+ emptyState: "fxview-empty-state",
+ lists: { all: "fxview-tab-list" },
+ showAllHistoryBtn: ".show-all-history-button",
+ searchTextbox: "fxview-search-textbox",
+ sortInputs: { all: "input[name=history-sort-option]" },
+ panelList: "panel-list",
+ };
+
+ static properties = {
+ ...ViewPage.properties,
+ allHistoryItems: { type: Map },
+ historyMapByDate: { type: Array },
+ historyMapBySite: { type: Array },
+ // Making profileAge a reactive property for testing
+ profileAge: { type: Number },
+ searchResults: { type: Array },
+ sortOption: { type: String },
+ };
+
+ async getUpdateComplete() {
+ await super.getUpdateComplete();
+ await Promise.all(Array.from(this.cards).map(card => card.updateComplete));
+ }
+
+ async updateHistoryData() {
+ this.allHistoryItems = await this.placesQuery.getHistory({
+ daysOld: 60,
+ limit: lazy.maxRowsPref,
+ sortBy: this.sortOption,
+ });
+ }
+
+ resetHistoryMaps() {
+ this.historyMapByDate = [];
+ this.historyMapBySite = [];
+ }
+
+ createHistoryMaps() {
+ if (this.sortOption === "date" && !this.historyMapByDate.length) {
+ const {
+ visitsFromToday,
+ visitsFromYesterday,
+ visitsByDay,
+ visitsByMonth,
+ } = this.placesQuery;
+
+ // Add visits from today and yesterday.
+ if (visitsFromToday.length) {
+ this.historyMapByDate.push({
+ l10nId: "firefoxview-history-date-today",
+ items: visitsFromToday,
+ });
+ }
+ if (visitsFromYesterday.length) {
+ this.historyMapByDate.push({
+ l10nId: "firefoxview-history-date-yesterday",
+ items: visitsFromYesterday,
+ });
+ }
+
+ // Add visits from this month, grouped by day.
+ visitsByDay.forEach(visits => {
+ this.historyMapByDate.push({
+ l10nId: "firefoxview-history-date-this-month",
+ items: visits,
+ });
+ });
+
+ // Add visits from previous months, grouped by month.
+ visitsByMonth.forEach(visits => {
+ this.historyMapByDate.push({
+ l10nId: "firefoxview-history-date-prev-month",
+ items: visits,
+ });
+ });
+ } else if (this.sortOption === "site" && !this.historyMapBySite.length) {
+ this.historyMapBySite = Array.from(
+ this.allHistoryItems.entries(),
+ ([domain, items]) => ({
+ domain,
+ items,
+ l10nId: domain ? null : "firefoxview-history-site-localhost",
+ })
+ ).sort((a, b) => a.domain.localeCompare(b.domain));
+ }
+ }
+
+ onPrimaryAction(e) {
+ // Record telemetry
+ Services.telemetry.recordEvent(
+ "firefoxview_next",
+ "history",
+ "visits",
+ null,
+ {}
+ );
+
+ if (this.searchQuery) {
+ const searchesHistogram = Services.telemetry.getKeyedHistogramById(
+ "FIREFOX_VIEW_CUMULATIVE_SEARCHES"
+ );
+ searchesHistogram.add("history", this.cumulativeSearches);
+ this.cumulativeSearches = 0;
+ }
+
+ let currentWindow = this.getWindow();
+ if (currentWindow.openTrustedLinkIn) {
+ let where = lazy.BrowserUtils.whereToOpenLink(
+ e.detail.originalEvent,
+ false,
+ true
+ );
+ if (where == "current") {
+ where = "tab";
+ }
+ currentWindow.openTrustedLinkIn(e.originalTarget.url, where);
+ }
+ }
+
+ onSecondaryAction(e) {
+ this.triggerNode = e.originalTarget;
+ e.target.querySelector("panel-list").toggle(e.detail.originalEvent);
+ }
+
+ deleteFromHistory(e) {
+ lazy.PlacesUtils.history.remove(this.triggerNode.url);
+ this.recordContextMenuTelemetry("delete-from-history", e);
+ }
+
+ async onChangeSortOption(e) {
+ this.sortOption = e.target.value;
+ Services.telemetry.recordEvent(
+ "firefoxview_next",
+ "sort_history",
+ "tabs",
+ null,
+ {
+ sort_type: this.sortOption,
+ search_start: this.searchQuery ? "true" : "false",
+ }
+ );
+ await this.updateHistoryData();
+ await this.#updateSearchResults();
+ }
+
+ showAllHistory() {
+ // Record telemetry
+ Services.telemetry.recordEvent(
+ "firefoxview_next",
+ "show_all_history",
+ "tabs",
+ null,
+ {}
+ );
+
+ // Open History view in Library window
+ this.getWindow().PlacesCommandHook.showPlacesOrganizer("History");
+ }
+
+ async openMigrationWizard() {
+ let migrationWizardDialog = this.migrationWizardDialog;
+
+ if (migrationWizardDialog.open) {
+ return;
+ }
+
+ await customElements.whenDefined("migration-wizard");
+
+ // If we've been opened before, remove the old wizard and insert a
+ // new one to put it back into its starting state.
+ if (!migrationWizardDialog.firstElementChild) {
+ let wizard = document.createElement("migration-wizard");
+ wizard.toggleAttribute("dialog-mode", true);
+ migrationWizardDialog.appendChild(wizard);
+ }
+ migrationWizardDialog.firstElementChild.requestState();
+
+ this.migrationWizardDialog.addEventListener(
+ "MigrationWizard:Close",
+ function (e) {
+ e.currentTarget.close();
+ }
+ );
+
+ migrationWizardDialog.showModal();
+ }
+
+ shouldShowImportBanner() {
+ return (
+ this.profileAge < 8 &&
+ !this.hasImportedHistoryPref &&
+ !this.importHistoryDismissedPref
+ );
+ }
+
+ dismissImportHistory() {
+ Services.prefs.setBoolPref(IMPORT_HISTORY_DISMISSED_PREF, true);
+ }
+
+ updated() {
+ this.fullyUpdated = true;
+ if (this.lists?.length) {
+ this.toggleVisibilityInCardContainer();
+ }
+ }
+
+ panelListTemplate() {
+ return html`
+ <panel-list slot="menu" data-tab-type="history">
+ <panel-item
+ @click=${this.deleteFromHistory}
+ data-l10n-id="firefoxview-history-context-delete"
+ data-l10n-attrs="accesskey"
+ ></panel-item>
+ <hr />
+ <panel-item
+ @click=${this.openInNewWindow}
+ data-l10n-id="fxviewtabrow-open-in-window"
+ data-l10n-attrs="accesskey"
+ ></panel-item>
+ <panel-item
+ @click=${this.openInNewPrivateWindow}
+ data-l10n-id="fxviewtabrow-open-in-private-window"
+ data-l10n-attrs="accesskey"
+ ></panel-item>
+ <hr />
+ <panel-item
+ @click=${this.copyLink}
+ data-l10n-id="fxviewtabrow-copy-link"
+ data-l10n-attrs="accesskey"
+ ></panel-item>
+ </panel-list>
+ `;
+ }
+
+ /**
+ * The template to use for cards-container.
+ */
+ get cardsTemplate() {
+ if (this.searchResults) {
+ return this.#searchResultsTemplate();
+ } else if (this.allHistoryItems.size) {
+ return this.#historyCardsTemplate();
+ }
+ return this.#emptyMessageTemplate();
+ }
+
+ #historyCardsTemplate() {
+ let cardsTemplate = [];
+ if (this.sortOption === "date" && this.historyMapByDate.length) {
+ this.historyMapByDate.forEach(historyItem => {
+ if (historyItem.items.length) {
+ let dateArg = JSON.stringify({ date: historyItem.items[0].time });
+ cardsTemplate.push(html`<card-container>
+ <h3
+ slot="header"
+ data-l10n-id=${historyItem.l10nId}
+ data-l10n-args=${dateArg}
+ ></h3>
+ <fxview-tab-list
+ slot="main"
+ class="with-context-menu"
+ dateTimeFormat=${historyItem.l10nId.includes("prev-month")
+ ? "dateTime"
+ : "time"}
+ hasPopup="menu"
+ maxTabsLength=${this.maxTabsLength}
+ .tabItems=${historyItem.items}
+ @fxview-tab-list-primary-action=${this.onPrimaryAction}
+ @fxview-tab-list-secondary-action=${this.onSecondaryAction}
+ >
+ ${this.panelListTemplate()}
+ </fxview-tab-list>
+ </card-container>`);
+ }
+ });
+ } else if (this.historyMapBySite.length) {
+ this.historyMapBySite.forEach(historyItem => {
+ if (historyItem.items.length) {
+ cardsTemplate.push(html`<card-container>
+ <h3 slot="header" data-l10n-id="${ifDefined(historyItem.l10nId)}">
+ ${historyItem.domain}
+ </h3>
+ <fxview-tab-list
+ slot="main"
+ class="with-context-menu"
+ dateTimeFormat="dateTime"
+ hasPopup="menu"
+ maxTabsLength=${this.maxTabsLength}
+ .tabItems=${historyItem.items}
+ @fxview-tab-list-primary-action=${this.onPrimaryAction}
+ @fxview-tab-list-secondary-action=${this.onSecondaryAction}
+ >
+ ${this.panelListTemplate()}
+ </fxview-tab-list>
+ </card-container>`);
+ }
+ });
+ }
+ return cardsTemplate;
+ }
+
+ #emptyMessageTemplate() {
+ let descriptionHeader;
+ let descriptionLabels;
+ let descriptionLink;
+ if (Services.prefs.getBoolPref(NEVER_REMEMBER_HISTORY_PREF, false)) {
+ // History pref set to never remember history
+ descriptionHeader = "firefoxview-dont-remember-history-empty-header";
+ descriptionLabels = [
+ "firefoxview-dont-remember-history-empty-description",
+ "firefoxview-dont-remember-history-empty-description-two",
+ ];
+ descriptionLink = {
+ url: "about:preferences#privacy",
+ name: "history-settings-url-two",
+ };
+ } else {
+ descriptionHeader = "firefoxview-history-empty-header";
+ descriptionLabels = [
+ "firefoxview-history-empty-description",
+ "firefoxview-history-empty-description-two",
+ ];
+ descriptionLink = {
+ url: "about:preferences#privacy",
+ name: "history-settings-url",
+ };
+ }
+ return html`
+ <fxview-empty-state
+ headerLabel=${descriptionHeader}
+ .descriptionLabels=${descriptionLabels}
+ .descriptionLink=${descriptionLink}
+ class="empty-state history"
+ ?isSelectedTab=${this.selectedTab}
+ mainImageUrl="chrome://browser/content/firefoxview/history-empty.svg"
+ >
+ </fxview-empty-state>
+ `;
+ }
+
+ #searchResultsTemplate() {
+ return html` <card-container toggleDisabled>
+ <h3
+ slot="header"
+ data-l10n-id="firefoxview-search-results-header"
+ data-l10n-args=${JSON.stringify({
+ query: escapeHtmlEntities(this.searchQuery),
+ })}
+ ></h3>
+ ${when(
+ this.searchResults.length,
+ () =>
+ html`<h3
+ slot="secondary-header"
+ data-l10n-id="firefoxview-search-results-count"
+ data-l10n-args="${JSON.stringify({
+ count: this.searchResults.length,
+ })}"
+ ></h3>`
+ )}
+ <fxview-tab-list
+ slot="main"
+ class="with-context-menu"
+ dateTimeFormat="dateTime"
+ hasPopup="menu"
+ maxTabsLength="-1"
+ .searchQuery=${this.searchQuery}
+ .tabItems=${this.searchResults}
+ @fxview-tab-list-primary-action=${this.onPrimaryAction}
+ @fxview-tab-list-secondary-action=${this.onSecondaryAction}
+ >
+ ${this.panelListTemplate()}
+ </fxview-tab-list>
+ </card-container>`;
+ }
+
+ render() {
+ if (!this.selectedTab) {
+ return null;
+ }
+ return html`
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/firefoxview/firefoxview.css"
+ />
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/firefoxview/history.css"
+ />
+ <dialog id="migrationWizardDialog"></dialog>
+ <div class="sticky-container bottom-fade">
+ <h2 class="page-header" data-l10n-id="firefoxview-history-header"></h2>
+ <div class="history-sort-options">
+ ${when(
+ isSearchEnabled(),
+ () => html` <div class="history-sort-option">
+ <fxview-search-textbox
+ data-l10n-id="firefoxview-search-text-box-history"
+ data-l10n-attrs="placeholder"
+ .size=${this.searchTextboxSize}
+ pageName=${this.recentBrowsing ? "recentbrowsing" : "history"}
+ @fxview-search-textbox-query=${this.onSearchQuery}
+ ></fxview-search-textbox>
+ </div>`
+ )}
+ <div class="history-sort-option">
+ <input
+ type="radio"
+ id="sort-by-date"
+ name="history-sort-option"
+ value="date"
+ ?checked=${this.sortOption === "date"}
+ @click=${this.onChangeSortOption}
+ />
+ <label
+ for="sort-by-date"
+ data-l10n-id="firefoxview-sort-history-by-date-label"
+ ></label>
+ </div>
+ <div class="history-sort-option">
+ <input
+ type="radio"
+ id="sort-by-site"
+ name="history-sort-option"
+ value="site"
+ ?checked=${this.sortOption === "site"}
+ @click=${this.onChangeSortOption}
+ />
+ <label
+ for="sort-by-site"
+ data-l10n-id="firefoxview-sort-history-by-site-label"
+ ></label>
+ </div>
+ </div>
+ </div>
+ <div class="cards-container">
+ <card-container
+ class="import-history-banner"
+ hideHeader="true"
+ ?hidden=${!this.shouldShowImportBanner()}
+ >
+ <div slot="main">
+ <div class="banner-text">
+ <span data-l10n-id="firefoxview-import-history-header"></span>
+ <span
+ data-l10n-id="firefoxview-import-history-description"
+ ></span>
+ </div>
+ <div class="buttons">
+ <button
+ class="primary choose-browser"
+ data-l10n-id="firefoxview-choose-browser-button"
+ @click=${this.openMigrationWizard}
+ ></button>
+ <button
+ class="close ghost-button"
+ data-l10n-id="firefoxview-import-history-close-button"
+ @click=${this.dismissImportHistory}
+ ></button>
+ </div>
+ </div>
+ </card-container>
+ ${this.cardsTemplate}
+ </div>
+ <div
+ class="show-all-history-footer"
+ ?hidden=${!this.allHistoryItems.size}
+ >
+ <button
+ class="show-all-history-button"
+ data-l10n-id="firefoxview-show-all-history"
+ @click=${this.showAllHistory}
+ ?hidden=${this.searchResults}
+ ></button>
+ </div>
+ `;
+ }
+
+ async onSearchQuery(e) {
+ this.searchQuery = e.detail.query;
+ this.cumulativeSearches = this.searchQuery
+ ? this.cumulativeSearches + 1
+ : 0;
+ this.#updateSearchResults();
+ }
+
+ willUpdate(changedProperties) {
+ this.fullyUpdated = false;
+ if (this.allHistoryItems.size && !changedProperties.has("sortOption")) {
+ // onChangeSortOption() will update history data once it has been fetched
+ // from the API.
+ this.createHistoryMaps();
+ }
+ }
+}
+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..27eeaaef80
--- /dev/null
+++ b/browser/components/firefoxview/jar.mn
@@ -0,0 +1,40 @@
+# 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.mjs
+ content/browser/firefoxview/history.css
+ content/browser/firefoxview/history.mjs
+ content/browser/firefoxview/opentabs.mjs
+ content/browser/firefoxview/view-opentabs.css
+ content/browser/firefoxview/syncedtabs.mjs
+ content/browser/firefoxview/view-syncedtabs.css
+ content/browser/firefoxview/recentbrowsing.mjs
+ content/browser/firefoxview/firefoxview.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/fxview-empty-state.css
+ content/browser/firefoxview/fxview-empty-state.mjs
+ content/browser/firefoxview/helpers.mjs
+ content/browser/firefoxview/fxview-search-textbox.css
+ content/browser/firefoxview/fxview-search-textbox.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/recentlyclosed.mjs
+ content/browser/firefoxview/viewpage.mjs
+ content/browser/firefoxview/history-empty.svg (content/history-empty.svg)
+ content/browser/firefoxview/category-history.svg (content/category-history.svg)
+ content/browser/firefoxview/category-opentabs.svg (content/category-opentabs.svg)
+ content/browser/firefoxview/category-recentbrowsing.svg (content/category-recentbrowsing.svg)
+ content/browser/firefoxview/category-recentlyclosed.svg (content/category-recentlyclosed.svg)
+ content/browser/firefoxview/category-syncedtabs.svg (content/category-syncedtabs.svg)
+ content/browser/firefoxview/recentlyclosed-empty.svg (content/recentlyclosed-empty.svg)
+ content/browser/firefoxview/synced-tabs-error.svg (content/synced-tabs-error.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)
diff --git a/browser/components/firefoxview/moz.build b/browser/components/firefoxview/moz.build
new file mode 100644
index 0000000000..894deceffd
--- /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.toml"]
+
+MOCHITEST_CHROME_MANIFESTS += ["tests/chrome/chrome.toml"]
diff --git a/browser/components/firefoxview/opentabs.mjs b/browser/components/firefoxview/opentabs.mjs
new file mode 100644
index 0000000000..6ac63a4b3f
--- /dev/null
+++ b/browser/components/firefoxview/opentabs.mjs
@@ -0,0 +1,834 @@
+/* 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 {
+ classMap,
+ html,
+ map,
+ when,
+} from "chrome://global/content/vendor/lit.all.mjs";
+import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
+import {
+ getLogger,
+ isSearchEnabled,
+ placeLinkOnClipboard,
+ searchTabList,
+ MAX_TABS_FOR_RECENT_BROWSING,
+} from "./helpers.mjs";
+import { ViewPage, ViewPageContent } from "./viewpage.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ContextualIdentityService:
+ "resource://gre/modules/ContextualIdentityService.sys.mjs",
+ NonPrivateTabs: "resource:///modules/OpenTabs.sys.mjs",
+ getTabsTargetForWindow: "resource:///modules/OpenTabs.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => {
+ return ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+ ).getFxAccountsSingleton();
+});
+
+/**
+ * A collection of open tabs grouped by window.
+ *
+ * @property {Array<Window>} windows
+ * A list of windows with the same privateness
+ * @property {string} sortOption
+ * The sorting order of open tabs:
+ * - "recency": Sorted by recent activity. (For recent browsing, this is the only option.)
+ * - "tabStripOrder": Match the order in which they appear on the tab strip.
+ */
+class OpenTabsInView extends ViewPage {
+ static properties = {
+ ...ViewPage.properties,
+ windows: { type: Array },
+ searchQuery: { type: String },
+ sortOption: { type: String },
+ };
+ static queries = {
+ viewCards: { all: "view-opentabs-card" },
+ optionsContainer: ".open-tabs-options",
+ searchTextbox: "fxview-search-textbox",
+ };
+
+ initialWindowsReady = false;
+ currentWindow = null;
+ openTabsTarget = null;
+
+ constructor() {
+ super();
+ this._started = false;
+ this.windows = [];
+ this.currentWindow = this.getWindow();
+ if (lazy.PrivateBrowsingUtils.isWindowPrivate(this.currentWindow)) {
+ this.openTabsTarget = lazy.getTabsTargetForWindow(this.currentWindow);
+ } else {
+ this.openTabsTarget = lazy.NonPrivateTabs;
+ }
+ this.searchQuery = "";
+ this.sortOption = this.recentBrowsing
+ ? "recency"
+ : Services.prefs.getStringPref(
+ "browser.tabs.firefox-view.ui-state.opentabs.sort-option",
+ "recency"
+ );
+ }
+
+ start() {
+ if (this._started) {
+ return;
+ }
+ this._started = true;
+ this.#setupTabChangeListener();
+
+ // To resolve the race between this component wanting to render all the windows'
+ // tabs, while those windows are still potentially opening, flip this property
+ // once the promise resolves and we'll bail out of rendering until then.
+ this.openTabsTarget.readyWindowsPromise.finally(() => {
+ this.initialWindowsReady = true;
+ this._updateWindowList();
+ });
+
+ for (let card of this.viewCards) {
+ card.paused = false;
+ card.viewVisibleCallback?.();
+ }
+
+ if (this.recentBrowsing) {
+ this.recentBrowsingElement.addEventListener(
+ "fxview-search-textbox-query",
+ this
+ );
+ }
+ }
+
+ shouldUpdate(changedProperties) {
+ if (!this.initialWindowsReady) {
+ return false;
+ }
+ return super.shouldUpdate(changedProperties);
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this.stop();
+ }
+
+ stop() {
+ if (!this._started) {
+ return;
+ }
+ this._started = false;
+ this.paused = true;
+
+ this.openTabsTarget.removeEventListener("TabChange", this);
+ this.openTabsTarget.removeEventListener("TabRecencyChange", this);
+
+ for (let card of this.viewCards) {
+ card.paused = true;
+ card.viewHiddenCallback?.();
+ }
+
+ if (this.recentBrowsing) {
+ this.recentBrowsingElement.removeEventListener(
+ "fxview-search-textbox-query",
+ this
+ );
+ }
+ }
+
+ viewVisibleCallback() {
+ this.start();
+ }
+
+ viewHiddenCallback() {
+ this.stop();
+ }
+
+ #setupTabChangeListener() {
+ if (this.sortOption === "recency") {
+ this.openTabsTarget.addEventListener("TabRecencyChange", this);
+ this.openTabsTarget.removeEventListener("TabChange", this);
+ } else {
+ this.openTabsTarget.removeEventListener("TabRecencyChange", this);
+ this.openTabsTarget.addEventListener("TabChange", this);
+ }
+ }
+
+ render() {
+ if (this.recentBrowsing) {
+ return this.getRecentBrowsingTemplate();
+ }
+ let currentWindowIndex, currentWindowTabs;
+ let index = 1;
+ const otherWindows = [];
+ this.windows.forEach(win => {
+ const tabs = this.openTabsTarget.getTabsForWindow(
+ win,
+ this.sortOption === "recency"
+ );
+ if (win === this.currentWindow) {
+ currentWindowIndex = index++;
+ currentWindowTabs = tabs;
+ } else {
+ otherWindows.push([index++, tabs, win]);
+ }
+ });
+
+ const cardClasses = classMap({
+ "height-limited": this.windows.length > 3,
+ "width-limited": this.windows.length > 1,
+ });
+ let cardCount;
+ if (this.windows.length <= 1) {
+ cardCount = "one";
+ } else if (this.windows.length === 2) {
+ cardCount = "two";
+ } else {
+ cardCount = "three-or-more";
+ }
+ return html`
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/firefoxview/view-opentabs.css"
+ />
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/firefoxview/firefoxview.css"
+ />
+ <div class="sticky-container bottom-fade">
+ <h2 class="page-header" data-l10n-id="firefoxview-opentabs-header"></h2>
+ <div class="open-tabs-options">
+ ${when(
+ isSearchEnabled(),
+ () => html`<div>
+ <fxview-search-textbox
+ data-l10n-id="firefoxview-search-text-box-opentabs"
+ data-l10n-attrs="placeholder"
+ @fxview-search-textbox-query=${this.onSearchQuery}
+ .size=${this.searchTextboxSize}
+ pageName=${this.recentBrowsing ? "recentbrowsing" : "opentabs"}
+ ></fxview-search-textbox>
+ </div>`
+ )}
+ <div class="open-tabs-sort-wrapper">
+ <div class="open-tabs-sort-option">
+ <input
+ type="radio"
+ id="sort-by-recency"
+ name="open-tabs-sort-option"
+ value="recency"
+ ?checked=${this.sortOption === "recency"}
+ @click=${this.onChangeSortOption}
+ />
+ <label
+ for="sort-by-recency"
+ data-l10n-id="firefoxview-sort-open-tabs-by-recency-label"
+ ></label>
+ </div>
+ <div class="open-tabs-sort-option">
+ <input
+ type="radio"
+ id="sort-by-order"
+ name="open-tabs-sort-option"
+ value="tabStripOrder"
+ ?checked=${this.sortOption === "tabStripOrder"}
+ @click=${this.onChangeSortOption}
+ />
+ <label
+ for="sort-by-order"
+ data-l10n-id="firefoxview-sort-open-tabs-by-order-label"
+ ></label>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div
+ card-count=${cardCount}
+ class="view-opentabs-card-container cards-container"
+ >
+ ${when(
+ currentWindowIndex && currentWindowTabs,
+ () =>
+ html`
+ <view-opentabs-card
+ class=${cardClasses}
+ .tabs=${currentWindowTabs}
+ .paused=${this.paused}
+ data-inner-id="${this.currentWindow.windowGlobalChild
+ .innerWindowId}"
+ data-l10n-id="firefoxview-opentabs-current-window-header"
+ data-l10n-args="${JSON.stringify({
+ winID: currentWindowIndex,
+ })}"
+ .searchQuery=${this.searchQuery}
+ ></view-opentabs-card>
+ `
+ )}
+ ${map(
+ otherWindows,
+ ([winID, tabs, win]) => html`
+ <view-opentabs-card
+ class=${cardClasses}
+ .tabs=${tabs}
+ .paused=${this.paused}
+ data-inner-id="${win.windowGlobalChild.innerWindowId}"
+ data-l10n-id="firefoxview-opentabs-window-header"
+ data-l10n-args="${JSON.stringify({ winID })}"
+ .searchQuery=${this.searchQuery}
+ ></view-opentabs-card>
+ `
+ )}
+ </div>
+ `;
+ }
+
+ onSearchQuery(e) {
+ this.searchQuery = e.detail.query;
+ }
+
+ onChangeSortOption(e) {
+ this.sortOption = e.target.value;
+ this.#setupTabChangeListener();
+ if (!this.recentBrowsing) {
+ Services.prefs.setStringPref(
+ "browser.tabs.firefox-view.ui-state.opentabs.sort-option",
+ this.sortOption
+ );
+ }
+ }
+
+ /**
+ * Render a template for the 'Recent browsing' page, which shows a shorter list of
+ * open tabs in the current window.
+ *
+ * @returns {TemplateResult}
+ * The recent browsing template.
+ */
+ getRecentBrowsingTemplate() {
+ const tabs = this.openTabsTarget.getRecentTabs();
+ return html`<view-opentabs-card
+ .tabs=${tabs}
+ .recentBrowsing=${true}
+ .paused=${this.paused}
+ .searchQuery=${this.searchQuery}
+ ></view-opentabs-card>`;
+ }
+
+ handleEvent({ detail, target, type }) {
+ if (this.recentBrowsing && type === "fxview-search-textbox-query") {
+ this.onSearchQuery({ detail });
+ return;
+ }
+ let windowIds;
+ switch (type) {
+ case "TabRecencyChange":
+ case "TabChange":
+ // if we're switching away from our tab, we can halt any updates immediately
+ if (!this.isSelectedBrowserTab) {
+ this.stop();
+ return;
+ }
+ windowIds = detail.windowIds;
+ this._updateWindowList();
+ break;
+ }
+ if (this.recentBrowsing) {
+ return;
+ }
+ if (windowIds?.length) {
+ // there were tab changes to one or more windows
+ for (let winId of windowIds) {
+ const cardForWin = this.shadowRoot.querySelector(
+ `view-opentabs-card[data-inner-id="${winId}"]`
+ );
+ if (this.searchQuery) {
+ cardForWin?.updateSearchResults();
+ }
+ cardForWin?.requestUpdate();
+ }
+ } else {
+ let winId = window.windowGlobalChild.innerWindowId;
+ let cardForWin = this.shadowRoot.querySelector(
+ `view-opentabs-card[data-inner-id="${winId}"]`
+ );
+ if (this.searchQuery) {
+ cardForWin?.updateSearchResults();
+ }
+ }
+ }
+
+ async _updateWindowList() {
+ this.windows = this.openTabsTarget.currentWindows;
+ }
+}
+customElements.define("view-opentabs", OpenTabsInView);
+
+/**
+ * A card which displays a list of open tabs for a window.
+ *
+ * @property {boolean} showMore
+ * Whether to force all tabs to be shown, regardless of available space.
+ * @property {MozTabbrowserTab[]} tabs
+ * The open tabs to show.
+ * @property {string} title
+ * The window title.
+ */
+class OpenTabsInViewCard extends ViewPageContent {
+ static properties = {
+ showMore: { type: Boolean },
+ tabs: { type: Array },
+ title: { type: String },
+ recentBrowsing: { type: Boolean },
+ searchQuery: { type: String },
+ searchResults: { type: Array },
+ showAll: { type: Boolean },
+ cumulativeSearches: { type: Number },
+ };
+ static MAX_TABS_FOR_COMPACT_HEIGHT = 7;
+
+ constructor() {
+ super();
+ this.showMore = false;
+ this.tabs = [];
+ this.title = "";
+ this.recentBrowsing = false;
+ this.devices = [];
+ this.searchQuery = "";
+ this.searchResults = null;
+ this.showAll = false;
+ this.cumulativeSearches = 0;
+ }
+
+ static queries = {
+ cardEl: "card-container",
+ tabContextMenu: "view-opentabs-contextmenu",
+ tabList: "fxview-tab-list",
+ };
+
+ openContextMenu(e) {
+ let { originalEvent } = e.detail;
+ this.tabContextMenu.toggle({
+ triggerNode: e.originalTarget,
+ originalEvent,
+ });
+ }
+
+ getMaxTabsLength() {
+ if (this.recentBrowsing && !this.showAll) {
+ return MAX_TABS_FOR_RECENT_BROWSING;
+ } else if (this.classList.contains("height-limited") && !this.showMore) {
+ return OpenTabsInViewCard.MAX_TABS_FOR_COMPACT_HEIGHT;
+ }
+ return -1;
+ }
+
+ isShowAllLinkVisible() {
+ return (
+ this.recentBrowsing &&
+ this.searchQuery &&
+ this.searchResults.length > MAX_TABS_FOR_RECENT_BROWSING &&
+ !this.showAll
+ );
+ }
+
+ toggleShowMore(event) {
+ if (
+ event.type == "click" ||
+ (event.type == "keydown" && event.code == "Enter") ||
+ (event.type == "keydown" && event.code == "Space")
+ ) {
+ event.preventDefault();
+ this.showMore = !this.showMore;
+ }
+ }
+
+ enableShowAll(event) {
+ if (
+ event.type == "click" ||
+ (event.type == "keydown" && event.code == "Enter") ||
+ (event.type == "keydown" && event.code == "Space")
+ ) {
+ event.preventDefault();
+ Services.telemetry.recordEvent(
+ "firefoxview_next",
+ "search_show_all",
+ "showallbutton",
+ null,
+ {
+ section: "opentabs",
+ }
+ );
+ this.showAll = true;
+ }
+ }
+
+ onTabListRowClick(event) {
+ const tab = event.originalTarget.tabElement;
+ const browserWindow = tab.ownerGlobal;
+ browserWindow.focus();
+ browserWindow.gBrowser.selectedTab = tab;
+
+ Services.telemetry.recordEvent(
+ "firefoxview_next",
+ "open_tab",
+ "tabs",
+ null,
+ {
+ page: this.recentBrowsing ? "recentbrowsing" : "opentabs",
+ window: this.title || "Window 1 (Current)",
+ }
+ );
+ if (this.searchQuery) {
+ const searchesHistogram = Services.telemetry.getKeyedHistogramById(
+ "FIREFOX_VIEW_CUMULATIVE_SEARCHES"
+ );
+ searchesHistogram.add(
+ this.recentBrowsing ? "recentbrowsing" : "opentabs",
+ this.cumulativeSearches
+ );
+ this.cumulativeSearches = 0;
+ }
+ }
+
+ viewVisibleCallback() {
+ this.getRootNode().host.toggleVisibilityInCardContainer(true);
+ }
+
+ viewHiddenCallback() {
+ this.getRootNode().host.toggleVisibilityInCardContainer(true);
+ }
+
+ firstUpdated() {
+ this.getRootNode().host.toggleVisibilityInCardContainer(true);
+ }
+
+ render() {
+ return html`
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/firefoxview/firefoxview.css"
+ />
+ <card-container
+ ?preserveCollapseState=${this.recentBrowsing}
+ shortPageName=${this.recentBrowsing ? "opentabs" : null}
+ ?showViewAll=${this.recentBrowsing}
+ ?removeBlockEndMargin=${!this.recentBrowsing}
+ >
+ ${when(
+ this.recentBrowsing,
+ () => html`<h3
+ slot="header"
+ data-l10n-id="firefoxview-opentabs-header"
+ ></h3>`,
+ () => html`<h3 slot="header">${this.title}</h3>`
+ )}
+ <div class="fxview-tab-list-container" slot="main">
+ <fxview-tab-list
+ class="with-context-menu"
+ .hasPopup=${"menu"}
+ ?compactRows=${this.classList.contains("width-limited")}
+ @fxview-tab-list-primary-action=${this.onTabListRowClick}
+ @fxview-tab-list-secondary-action=${this.openContextMenu}
+ .maxTabsLength=${this.getMaxTabsLength()}
+ .tabItems=${this.searchResults || getTabListItems(this.tabs)}
+ .searchQuery=${this.searchQuery}
+ .showTabIndicators=${true}
+ ><view-opentabs-contextmenu slot="menu"></view-opentabs-contextmenu>
+ </fxview-tab-list>
+ </div>
+ ${when(
+ this.recentBrowsing,
+ () => html` <div
+ @click=${this.enableShowAll}
+ @keydown=${this.enableShowAll}
+ data-l10n-id="firefoxview-show-all"
+ ?hidden=${!this.isShowAllLinkVisible()}
+ slot="footer"
+ tabindex="0"
+ role="link"
+ ></div>`,
+ () =>
+ html` <div
+ @click=${this.toggleShowMore}
+ @keydown=${this.toggleShowMore}
+ data-l10n-id="${this.showMore
+ ? "firefoxview-show-less"
+ : "firefoxview-show-more"}"
+ ?hidden=${!this.classList.contains("height-limited") ||
+ this.tabs.length <=
+ OpenTabsInViewCard.MAX_TABS_FOR_COMPACT_HEIGHT}
+ slot="footer"
+ tabindex="0"
+ role="link"
+ ></div>`
+ )}
+ </card-container>
+ `;
+ }
+
+ willUpdate(changedProperties) {
+ if (changedProperties.has("searchQuery")) {
+ this.showAll = false;
+ this.cumulativeSearches = this.searchQuery
+ ? this.cumulativeSearches + 1
+ : 0;
+ }
+ if (changedProperties.has("searchQuery") || changedProperties.has("tabs")) {
+ this.updateSearchResults();
+ }
+ }
+
+ updateSearchResults() {
+ this.searchResults = this.searchQuery
+ ? searchTabList(this.searchQuery, getTabListItems(this.tabs))
+ : null;
+ }
+}
+customElements.define("view-opentabs-card", OpenTabsInViewCard);
+
+/**
+ * A context menu of actions available for open tab list items.
+ */
+class OpenTabsContextMenu extends MozLitElement {
+ static properties = {
+ devices: { type: Array },
+ triggerNode: { type: Object },
+ };
+
+ static queries = {
+ panelList: "panel-list",
+ };
+
+ constructor() {
+ super();
+ this.triggerNode = null;
+ this.devices = [];
+ }
+
+ get logger() {
+ return getLogger("OpenTabsContextMenu");
+ }
+
+ get ownerViewPage() {
+ return this.ownerDocument.querySelector("view-opentabs");
+ }
+
+ async fetchDevices() {
+ const currentWindow = this.ownerViewPage.getWindow();
+ if (currentWindow?.gSync) {
+ try {
+ await lazy.fxAccounts.device.refreshDeviceList();
+ } catch (e) {
+ this.logger.warn("Could not refresh the FxA device list", e);
+ }
+ this.devices = currentWindow.gSync.getSendTabTargets();
+ }
+ }
+
+ async toggle({ triggerNode, originalEvent }) {
+ if (this.panelList?.open) {
+ // the menu will close so avoid all the other work to update its contents
+ this.panelList.toggle(originalEvent);
+ return;
+ }
+ this.triggerNode = triggerNode;
+ await this.fetchDevices();
+ await this.getUpdateComplete();
+ this.panelList.toggle(originalEvent);
+ }
+
+ copyLink(e) {
+ placeLinkOnClipboard(this.triggerNode.title, this.triggerNode.url);
+ this.ownerViewPage.recordContextMenuTelemetry("copy-link", e);
+ }
+
+ closeTab(e) {
+ const tab = this.triggerNode.tabElement;
+ tab?.ownerGlobal.gBrowser.removeTab(tab);
+ this.ownerViewPage.recordContextMenuTelemetry("close-tab", e);
+ }
+
+ moveTabsToStart(e) {
+ const tab = this.triggerNode.tabElement;
+ tab?.ownerGlobal.gBrowser.moveTabsToStart(tab);
+ this.ownerViewPage.recordContextMenuTelemetry("move-tab-start", e);
+ }
+
+ moveTabsToEnd(e) {
+ const tab = this.triggerNode.tabElement;
+ tab?.ownerGlobal.gBrowser.moveTabsToEnd(tab);
+ this.ownerViewPage.recordContextMenuTelemetry("move-tab-end", e);
+ }
+
+ moveTabsToWindow(e) {
+ const tab = this.triggerNode.tabElement;
+ tab?.ownerGlobal.gBrowser.replaceTabsWithWindow(tab);
+ this.ownerViewPage.recordContextMenuTelemetry("move-tab-window", e);
+ }
+
+ moveMenuTemplate() {
+ const tab = this.triggerNode?.tabElement;
+ if (!tab) {
+ return null;
+ }
+ const browserWindow = tab.ownerGlobal;
+ const tabs = browserWindow?.gBrowser.visibleTabs || [];
+ const position = tabs.indexOf(tab);
+
+ return html`
+ <panel-list slot="submenu" id="move-tab-menu">
+ ${position > 0
+ ? html`<panel-item
+ @click=${this.moveTabsToStart}
+ data-l10n-id="fxviewtabrow-move-tab-start"
+ data-l10n-attrs="accesskey"
+ ></panel-item>`
+ : null}
+ ${position < tabs.length - 1
+ ? html`<panel-item
+ @click=${this.moveTabsToEnd}
+ data-l10n-id="fxviewtabrow-move-tab-end"
+ data-l10n-attrs="accesskey"
+ ></panel-item>`
+ : null}
+ <panel-item
+ @click=${this.moveTabsToWindow}
+ data-l10n-id="fxviewtabrow-move-tab-window"
+ data-l10n-attrs="accesskey"
+ ></panel-item>
+ </panel-list>
+ `;
+ }
+
+ async sendTabToDevice(e) {
+ let deviceId = e.target.getAttribute("device-id");
+ let device = this.devices.find(dev => dev.id == deviceId);
+ const viewPage = this.ownerViewPage;
+ viewPage.recordContextMenuTelemetry("send-tab-device", e);
+
+ if (device && this.triggerNode) {
+ await viewPage
+ .getWindow()
+ .gSync.sendTabToDevice(
+ this.triggerNode.url,
+ [device],
+ this.triggerNode.title
+ );
+ }
+ }
+
+ sendTabTemplate() {
+ return html` <panel-list slot="submenu" id="send-tab-menu">
+ ${this.devices.map(device => {
+ return html`
+ <panel-item @click=${this.sendTabToDevice} device-id=${device.id}
+ >${device.name}</panel-item
+ >
+ `;
+ })}
+ </panel-list>`;
+ }
+
+ render() {
+ const tab = this.triggerNode?.tabElement;
+ if (!tab) {
+ return null;
+ }
+
+ return html`
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/firefoxview/firefoxview.css"
+ />
+ <panel-list data-tab-type="opentabs">
+ <panel-item
+ data-l10n-id="fxviewtabrow-close-tab"
+ data-l10n-attrs="accesskey"
+ @click=${this.closeTab}
+ ></panel-item>
+ <panel-item
+ data-l10n-id="fxviewtabrow-move-tab"
+ data-l10n-attrs="accesskey"
+ submenu="move-tab-menu"
+ >${this.moveMenuTemplate()}</panel-item
+ >
+ <hr />
+ <panel-item
+ data-l10n-id="fxviewtabrow-copy-link"
+ data-l10n-attrs="accesskey"
+ @click=${this.copyLink}
+ ></panel-item>
+ ${this.devices.length >= 1
+ ? html`<panel-item
+ data-l10n-id="fxviewtabrow-send-tab"
+ data-l10n-attrs="accesskey"
+ submenu="send-tab-menu"
+ >${this.sendTabTemplate()}</panel-item
+ >`
+ : null}
+ </panel-list>
+ `;
+ }
+}
+customElements.define("view-opentabs-contextmenu", OpenTabsContextMenu);
+
+/**
+ * Checks if a given tab is within a container (contextual identity)
+ *
+ * @param {MozTabbrowserTab[]} tab
+ * Tab to fetch container info on.
+ * @returns {object[]}
+ * Container object.
+ */
+function getContainerObj(tab) {
+ let userContextId = tab.getAttribute("usercontextid");
+ let containerObj = null;
+ if (userContextId) {
+ containerObj =
+ lazy.ContextualIdentityService.getPublicIdentityFromId(userContextId);
+ }
+ return containerObj;
+}
+
+/**
+ * Convert a list of tabs into the format expected by the fxview-tab-list
+ * component.
+ *
+ * @param {MozTabbrowserTab[]} tabs
+ * Tabs to format.
+ * @returns {object[]}
+ * Formatted objects.
+ */
+function getTabListItems(tabs) {
+ let filtered = tabs?.filter(
+ tab => !tab.closing && !tab.hidden && !tab.pinned
+ );
+
+ return filtered.map(tab => {
+ const url = tab.linkedBrowser?.currentURI?.spec || "";
+ return {
+ attention: tab.hasAttribute("attention"),
+ containerObj: getContainerObj(tab),
+ icon: tab.getAttribute("image"),
+ muted: tab.hasAttribute("muted"),
+ pinned: tab.pinned,
+ primaryL10nId: "firefoxview-opentabs-tab-row",
+ primaryL10nArgs: JSON.stringify({ url }),
+ secondaryL10nId: "fxviewtabrow-options-menu-button",
+ secondaryL10nArgs: JSON.stringify({ tabTitle: tab.label }),
+ soundPlaying: tab.hasAttribute("soundplaying"),
+ tabElement: tab,
+ time: tab.lastAccessed,
+ title: tab.label,
+ titleChanged: tab.hasAttribute("titlechanged"),
+ url,
+ };
+ });
+}
diff --git a/browser/components/firefoxview/recentbrowsing.mjs b/browser/components/firefoxview/recentbrowsing.mjs
new file mode 100644
index 0000000000..cd832d2c2f
--- /dev/null
+++ b/browser/components/firefoxview/recentbrowsing.mjs
@@ -0,0 +1,65 @@
+/* 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, when } from "chrome://global/content/vendor/lit.all.mjs";
+import { ViewPage } from "./viewpage.mjs";
+import { isSearchEnabled } from "./helpers.mjs";
+
+class RecentBrowsingInView extends ViewPage {
+ constructor() {
+ super();
+ this.pageType = "recentbrowsing";
+ }
+
+ static queries = {
+ searchTextbox: "fxview-search-textbox",
+ };
+
+ static properties = {
+ ...ViewPage.properties,
+ };
+
+ viewVisibleCallback() {
+ for (let child of this.children) {
+ let childView = child.firstElementChild;
+ childView.paused = false;
+ childView.viewVisibleCallback();
+ }
+ }
+
+ viewHiddenCallback() {
+ for (let child of this.children) {
+ let childView = child.firstElementChild;
+ childView.paused = true;
+ childView.viewHiddenCallback();
+ }
+ }
+
+ render() {
+ return html`
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/firefoxview/firefoxview.css"
+ />
+ <div class="sticky-container bottom-fade">
+ <h2 class="page-header" data-l10n-id="firefoxview-overview-header"></h2>
+ ${when(
+ isSearchEnabled(),
+ () => html`<div class="search-container">
+ <fxview-search-textbox
+ data-l10n-id="firefoxview-search-text-box-recentbrowsing"
+ data-l10n-attrs="placeholder"
+ .size=${this.searchTextboxSize}
+ pageName="recentbrowsing"
+ ></fxview-search-textbox>
+ </div>`
+ )}
+ </div>
+ <div class="cards-container">
+ <slot></slot>
+ </div>
+ `;
+ }
+}
+customElements.define("view-recentbrowsing", RecentBrowsingInView);
diff --git a/browser/components/firefoxview/recentlyclosed.mjs b/browser/components/firefoxview/recentlyclosed.mjs
new file mode 100644
index 0000000000..6e7e06c1f4
--- /dev/null
+++ b/browser/components/firefoxview/recentlyclosed.mjs
@@ -0,0 +1,473 @@
+/* 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 {
+ classMap,
+ html,
+ ifDefined,
+ when,
+} from "chrome://global/content/vendor/lit.all.mjs";
+import {
+ isSearchEnabled,
+ searchTabList,
+ MAX_TABS_FOR_RECENT_BROWSING,
+} from "./helpers.mjs";
+import { ViewPage } from "./viewpage.mjs";
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://browser/content/firefoxview/card-container.mjs";
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://browser/content/firefoxview/fxview-tab-list.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs",
+});
+
+const SS_NOTIFY_CLOSED_OBJECTS_CHANGED = "sessionstore-closed-objects-changed";
+const SS_NOTIFY_BROWSER_SHUTDOWN_FLUSH = "sessionstore-browser-shutdown-flush";
+const NEVER_REMEMBER_HISTORY_PREF = "browser.privatebrowsing.autostart";
+const INCLUDE_CLOSED_TABS_FROM_CLOSED_WINDOWS =
+ "browser.sessionstore.closedTabsFromClosedWindows";
+
+function getWindow() {
+ return window.browsingContext.embedderWindowGlobal.browsingContext.window;
+}
+
+class RecentlyClosedTabsInView extends ViewPage {
+ constructor() {
+ super();
+ this._started = false;
+ this.boundObserve = (...args) => this.observe(...args);
+ this.firstUpdateComplete = false;
+ this.fullyUpdated = false;
+ this.maxTabsLength = this.recentBrowsing
+ ? MAX_TABS_FOR_RECENT_BROWSING
+ : -1;
+ this.recentlyClosedTabs = [];
+ this.searchQuery = "";
+ this.searchResults = null;
+ this.showAll = false;
+ this.cumulativeSearches = 0;
+ }
+
+ static properties = {
+ ...ViewPage.properties,
+ searchResults: { type: Array },
+ showAll: { type: Boolean },
+ cumulativeSearches: { type: Number },
+ };
+
+ static queries = {
+ cardEl: "card-container",
+ emptyState: "fxview-empty-state",
+ searchTextbox: "fxview-search-textbox",
+ tabList: "fxview-tab-list",
+ };
+
+ observe(subject, topic, data) {
+ if (
+ topic == SS_NOTIFY_CLOSED_OBJECTS_CHANGED ||
+ (topic == SS_NOTIFY_BROWSER_SHUTDOWN_FLUSH &&
+ subject.ownerGlobal == getWindow())
+ ) {
+ this.updateRecentlyClosedTabs();
+ }
+ }
+
+ start() {
+ if (this._started) {
+ return;
+ }
+ this._started = true;
+ this.paused = false;
+ this.updateRecentlyClosedTabs();
+
+ Services.obs.addObserver(
+ this.boundObserve,
+ SS_NOTIFY_CLOSED_OBJECTS_CHANGED
+ );
+ Services.obs.addObserver(
+ this.boundObserve,
+ SS_NOTIFY_BROWSER_SHUTDOWN_FLUSH
+ );
+
+ if (this.recentBrowsing) {
+ this.recentBrowsingElement.addEventListener(
+ "fxview-search-textbox-query",
+ this
+ );
+ }
+
+ this.toggleVisibilityInCardContainer();
+ }
+
+ stop() {
+ if (!this._started) {
+ return;
+ }
+ this._started = false;
+
+ Services.obs.removeObserver(
+ this.boundObserve,
+ SS_NOTIFY_CLOSED_OBJECTS_CHANGED
+ );
+ Services.obs.removeObserver(
+ this.boundObserve,
+ SS_NOTIFY_BROWSER_SHUTDOWN_FLUSH
+ );
+
+ if (this.recentBrowsing) {
+ this.recentBrowsingElement.removeEventListener(
+ "fxview-search-textbox-query",
+ this
+ );
+ }
+
+ this.toggleVisibilityInCardContainer();
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this.stop();
+ }
+
+ handleEvent(event) {
+ if (this.recentBrowsing && event.type === "fxview-search-textbox-query") {
+ this.onSearchQuery(event);
+ }
+ }
+
+ // We remove all the observers when the instance is not visible to the user
+ viewHiddenCallback() {
+ this.stop();
+ }
+
+ // We add observers and check for changes to the session store once the user return to this tab.
+ // or the instance becomes visible to the user
+ viewVisibleCallback() {
+ this.start();
+ }
+
+ firstUpdated() {
+ this.firstUpdateComplete = true;
+ }
+
+ 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;
+ }
+
+ updateRecentlyClosedTabs() {
+ let recentlyClosedTabsData = lazy.SessionStore.getClosedTabData(
+ getWindow()
+ );
+ if (Services.prefs.getBoolPref(INCLUDE_CLOSED_TABS_FROM_CLOSED_WINDOWS)) {
+ recentlyClosedTabsData.push(
+ ...lazy.SessionStore.getClosedTabDataFromClosedWindows()
+ );
+ }
+ // sort the aggregated list to most-recently-closed first
+ recentlyClosedTabsData.sort((a, b) => a.closedAt < b.closedAt);
+ this.recentlyClosedTabs = recentlyClosedTabsData;
+ this.normalizeRecentlyClosedData();
+ if (this.searchQuery) {
+ this.#updateSearchResults();
+ }
+ this.requestUpdate();
+ }
+
+ normalizeRecentlyClosedData() {
+ // Normalize data for fxview-tabs-list
+ this.recentlyClosedTabs.forEach(recentlyClosedItem => {
+ const targetURI = this.getTabStateValue(recentlyClosedItem, "url");
+ recentlyClosedItem.time = recentlyClosedItem.closedAt;
+ recentlyClosedItem.icon = recentlyClosedItem.image;
+ recentlyClosedItem.primaryL10nId = "fxviewtabrow-tabs-list-tab";
+ recentlyClosedItem.primaryL10nArgs = JSON.stringify({
+ targetURI: typeof targetURI === "string" ? targetURI : "",
+ });
+ recentlyClosedItem.secondaryL10nId =
+ "firefoxview-closed-tabs-dismiss-tab";
+ recentlyClosedItem.secondaryL10nArgs = JSON.stringify({
+ tabTitle: recentlyClosedItem.title,
+ });
+ recentlyClosedItem.url = targetURI;
+ });
+ }
+
+ onReopenTab(e) {
+ const closedId = parseInt(e.originalTarget.closedId, 10);
+ const sourceClosedId = parseInt(e.originalTarget.sourceClosedId, 10);
+ if (isNaN(sourceClosedId)) {
+ lazy.SessionStore.undoCloseById(closedId, getWindow());
+ } else {
+ lazy.SessionStore.undoClosedTabFromClosedWindow(
+ { sourceClosedId },
+ closedId,
+ getWindow()
+ );
+ }
+
+ // Record telemetry
+ let tabClosedAt = parseInt(e.originalTarget.time);
+ const position =
+ Array.from(this.tabList.rowEls).indexOf(e.originalTarget) + 1;
+
+ let now = Date.now();
+ let deltaSeconds = (now - tabClosedAt) / 1000;
+ Services.telemetry.recordEvent(
+ "firefoxview_next",
+ "recently_closed",
+ "tabs",
+ null,
+ {
+ position: position.toString(),
+ delta: deltaSeconds.toString(),
+ page: this.recentBrowsing ? "recentbrowsing" : "recentlyclosed",
+ }
+ );
+ if (this.searchQuery) {
+ const searchesHistogram = Services.telemetry.getKeyedHistogramById(
+ "FIREFOX_VIEW_CUMULATIVE_SEARCHES"
+ );
+ searchesHistogram.add(
+ this.recentBrowsing ? "recentbrowsing" : "recentlyclosed",
+ this.cumulativeSearches
+ );
+ this.cumulativeSearches = 0;
+ }
+ }
+
+ onDismissTab(e) {
+ const closedId = parseInt(e.originalTarget.closedId, 10);
+ const sourceClosedId = parseInt(e.originalTarget.sourceClosedId, 10);
+ const sourceWindowId = e.originalTarget.souceWindowId;
+ if (sourceWindowId || !isNaN(sourceClosedId)) {
+ lazy.SessionStore.forgetClosedTabById(closedId, {
+ sourceClosedId,
+ sourceWindowId,
+ });
+ } else {
+ lazy.SessionStore.forgetClosedTabById(closedId);
+ }
+
+ // Record telemetry
+ let tabClosedAt = parseInt(e.originalTarget.time);
+ const position =
+ Array.from(this.tabList.rowEls).indexOf(e.originalTarget) + 1;
+
+ let now = Date.now();
+ let deltaSeconds = (now - tabClosedAt) / 1000;
+ Services.telemetry.recordEvent(
+ "firefoxview_next",
+ "dismiss_closed_tab",
+ "tabs",
+ null,
+ {
+ position: position.toString(),
+ delta: deltaSeconds.toString(),
+ page: this.recentBrowsing ? "recentbrowsing" : "recentlyclosed",
+ }
+ );
+ }
+
+ willUpdate() {
+ this.fullyUpdated = false;
+ }
+
+ updated() {
+ this.fullyUpdated = true;
+ this.toggleVisibilityInCardContainer();
+ }
+
+ async scheduleUpdate() {
+ // Only defer initial update
+ if (!this.firstUpdateComplete) {
+ await new Promise(resolve => setTimeout(resolve));
+ }
+ super.scheduleUpdate();
+ }
+
+ emptyMessageTemplate() {
+ let descriptionHeader;
+ let descriptionLabels;
+ let descriptionLink;
+ if (Services.prefs.getBoolPref(NEVER_REMEMBER_HISTORY_PREF, false)) {
+ // History pref set to never remember history
+ descriptionHeader = "firefoxview-dont-remember-history-empty-header";
+ descriptionLabels = [
+ "firefoxview-dont-remember-history-empty-description",
+ "firefoxview-dont-remember-history-empty-description-two",
+ ];
+ descriptionLink = {
+ url: "about:preferences#privacy",
+ name: "history-settings-url-two",
+ };
+ } else {
+ descriptionHeader = "firefoxview-recentlyclosed-empty-header";
+ descriptionLabels = [
+ "firefoxview-recentlyclosed-empty-description",
+ "firefoxview-recentlyclosed-empty-description-two",
+ ];
+ descriptionLink = {
+ url: "about:firefoxview#history",
+ name: "history-url",
+ sameTarget: "true",
+ };
+ }
+ return html`
+ <fxview-empty-state
+ headerLabel=${descriptionHeader}
+ .descriptionLabels=${descriptionLabels}
+ .descriptionLink=${descriptionLink}
+ class="empty-state recentlyclosed"
+ ?isInnerCard=${this.recentBrowsing}
+ ?isSelectedTab=${this.selectedTab}
+ mainImageUrl="chrome://browser/content/firefoxview/recentlyclosed-empty.svg"
+ >
+ </fxview-empty-state>
+ `;
+ }
+
+ render() {
+ return html`
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/firefoxview/firefoxview.css"
+ />
+ ${when(
+ !this.recentBrowsing,
+ () => html`<div
+ class="sticky-container bottom-fade"
+ ?hidden=${!this.selectedTab}
+ >
+ <h2
+ class="page-header"
+ data-l10n-id="firefoxview-recently-closed-header"
+ ></h2>
+ ${when(
+ isSearchEnabled(),
+ () => html`<div>
+ <fxview-search-textbox
+ data-l10n-id="firefoxview-search-text-box-recentlyclosed"
+ data-l10n-attrs="placeholder"
+ @fxview-search-textbox-query=${this.onSearchQuery}
+ .size=${this.searchTextboxSize}
+ pageName=${this.recentBrowsing
+ ? "recentbrowsing"
+ : "recentlyclosed"}
+ ></fxview-search-textbox>
+ </div>`
+ )}
+ </div>`
+ )}
+ <div class=${classMap({ "cards-container": this.selectedTab })}>
+ <card-container
+ shortPageName=${this.recentBrowsing ? "recentlyclosed" : null}
+ ?showViewAll=${this.recentBrowsing && this.recentlyClosedTabs.length}
+ ?preserveCollapseState=${this.recentBrowsing ? true : null}
+ ?hideHeader=${this.selectedTab}
+ ?hidden=${!this.recentlyClosedTabs.length && !this.recentBrowsing}
+ ?isEmptyState=${!this.recentlyClosedTabs.length}
+ >
+ <h3
+ slot="header"
+ data-l10n-id="firefoxview-recently-closed-header"
+ ></h3>
+ ${when(
+ this.recentlyClosedTabs.length,
+ () =>
+ html`
+ <fxview-tab-list
+ class="with-dismiss-button"
+ slot="main"
+ .maxTabsLength=${!this.recentBrowsing || this.showAll
+ ? -1
+ : MAX_TABS_FOR_RECENT_BROWSING}
+ .searchQuery=${ifDefined(
+ this.searchResults && this.searchQuery
+ )}
+ .tabItems=${this.searchResults || this.recentlyClosedTabs}
+ @fxview-tab-list-secondary-action=${this.onDismissTab}
+ @fxview-tab-list-primary-action=${this.onReopenTab}
+ ></fxview-tab-list>
+ `
+ )}
+ ${when(
+ this.recentBrowsing && !this.recentlyClosedTabs.length,
+ () => html` <div slot="main">${this.emptyMessageTemplate()}</div> `
+ )}
+ ${when(
+ this.isShowAllLinkVisible(),
+ () => html` <div
+ @click=${this.enableShowAll}
+ @keydown=${this.enableShowAll}
+ data-l10n-id="firefoxview-show-all"
+ ?hidden=${!this.isShowAllLinkVisible()}
+ slot="footer"
+ tabindex="0"
+ role="link"
+ ></div>`
+ )}
+ </card-container>
+ ${when(
+ this.selectedTab && !this.recentlyClosedTabs.length,
+ () => html` <div>${this.emptyMessageTemplate()}</div> `
+ )}
+ </div>
+ `;
+ }
+
+ onSearchQuery(e) {
+ this.searchQuery = e.detail.query;
+ this.showAll = false;
+ this.cumulativeSearches = this.searchQuery
+ ? this.cumulativeSearches + 1
+ : 0;
+ this.#updateSearchResults();
+ }
+
+ #updateSearchResults() {
+ this.searchResults = this.searchQuery
+ ? searchTabList(this.searchQuery, this.recentlyClosedTabs)
+ : null;
+ }
+
+ isShowAllLinkVisible() {
+ return (
+ this.recentBrowsing &&
+ this.searchQuery &&
+ this.searchResults.length > MAX_TABS_FOR_RECENT_BROWSING &&
+ !this.showAll
+ );
+ }
+
+ enableShowAll(event) {
+ if (
+ event.type == "click" ||
+ (event.type == "keydown" && event.code == "Enter") ||
+ (event.type == "keydown" && event.code == "Space")
+ ) {
+ event.preventDefault();
+ this.showAll = true;
+ Services.telemetry.recordEvent(
+ "firefoxview_next",
+ "search_show_all",
+ "showallbutton",
+ null,
+ {
+ section: "recentlyclosed",
+ }
+ );
+ }
+ }
+}
+customElements.define("view-recentlyclosed", RecentlyClosedTabsInView);
diff --git a/browser/components/firefoxview/syncedtabs.mjs b/browser/components/firefoxview/syncedtabs.mjs
new file mode 100644
index 0000000000..5320f8cb41
--- /dev/null
+++ b/browser/components/firefoxview/syncedtabs.mjs
@@ -0,0 +1,725 @@
+/* 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, {
+ BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
+ SyncedTabs: "resource://services-sync/SyncedTabs.sys.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"
+);
+
+import {
+ html,
+ ifDefined,
+ when,
+} from "chrome://global/content/vendor/lit.all.mjs";
+import { ViewPage } from "./viewpage.mjs";
+import {
+ escapeHtmlEntities,
+ isSearchEnabled,
+ searchTabList,
+ MAX_TABS_FOR_RECENT_BROWSING,
+} from "./helpers.mjs";
+
+const SYNCED_TABS_CHANGED = "services.sync.tabs.changed";
+const TOPIC_SETUPSTATE_CHANGED = "firefox-view.setupstate.changed";
+const UI_OPEN_STATE = "browser.tabs.firefox-view.ui-state.tab-pickup.open";
+
+class SyncedTabsInView extends ViewPage {
+ constructor() {
+ super();
+ this._started = false;
+ this.boundObserve = (...args) => this.observe(...args);
+ this._currentSetupStateIndex = -1;
+ this.errorState = null;
+ this._id = Math.floor(Math.random() * 10e6);
+ this.currentSyncedTabs = [];
+ if (this.recentBrowsing) {
+ this.maxTabsLength = MAX_TABS_FOR_RECENT_BROWSING;
+ } else {
+ // Setting maxTabsLength to -1 for no max
+ this.maxTabsLength = -1;
+ }
+ this.devices = [];
+ this.fullyUpdated = false;
+ this.searchQuery = "";
+ this.showAll = false;
+ this.cumulativeSearches = 0;
+ }
+
+ static properties = {
+ ...ViewPage.properties,
+ errorState: { type: Number },
+ currentSyncedTabs: { type: Array },
+ _currentSetupStateIndex: { type: Number },
+ devices: { type: Array },
+ searchQuery: { type: String },
+ showAll: { type: Boolean },
+ cumulativeSearches: { type: Number },
+ };
+
+ static queries = {
+ cardEls: { all: "card-container" },
+ emptyState: "fxview-empty-state",
+ searchTextbox: "fxview-search-textbox",
+ tabLists: { all: "fxview-tab-list" },
+ };
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.addEventListener("click", this);
+ }
+
+ start() {
+ if (this._started) {
+ return;
+ }
+ this._started = true;
+ Services.obs.addObserver(this.boundObserve, TOPIC_SETUPSTATE_CHANGED);
+ Services.obs.addObserver(this.boundObserve, SYNCED_TABS_CHANGED);
+
+ this.updateStates();
+ this.onVisibilityChange();
+
+ if (this.recentBrowsing) {
+ this.recentBrowsingElement.addEventListener(
+ "fxview-search-textbox-query",
+ this
+ );
+ }
+ }
+
+ stop() {
+ if (!this._started) {
+ return;
+ }
+ this._started = false;
+ TabsSetupFlowManager.updateViewVisibility(this._id, "unloaded");
+ this.onVisibilityChange();
+
+ Services.obs.removeObserver(this.boundObserve, TOPIC_SETUPSTATE_CHANGED);
+ Services.obs.removeObserver(this.boundObserve, SYNCED_TABS_CHANGED);
+
+ if (this.recentBrowsing) {
+ this.recentBrowsingElement.removeEventListener(
+ "fxview-search-textbox-query",
+ this
+ );
+ }
+ }
+
+ willUpdate(changedProperties) {
+ if (changedProperties.has("searchQuery")) {
+ this.cumulativeSearches = this.searchQuery
+ ? this.cumulativeSearches + 1
+ : 0;
+ }
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this.stop();
+ }
+
+ handleEvent(event) {
+ if (event.type == "click" && event.target.dataset.action) {
+ const { ErrorType } = SyncedTabsErrorHandler;
+ switch (event.target.dataset.action) {
+ case `${ErrorType.SYNC_ERROR}`:
+ case `${ErrorType.NETWORK_OFFLINE}`:
+ case `${ErrorType.PASSWORD_LOCKED}`: {
+ TabsSetupFlowManager.tryToClearError();
+ break;
+ }
+ case `${ErrorType.SIGNED_OUT}`:
+ case "sign-in": {
+ TabsSetupFlowManager.openFxASignup(event.target.ownerGlobal);
+ break;
+ }
+ case "add-device": {
+ TabsSetupFlowManager.openFxAPairDevice(event.target.ownerGlobal);
+ break;
+ }
+ case "sync-tabs-disabled": {
+ TabsSetupFlowManager.syncOpenTabs(event.target);
+ break;
+ }
+ case `${ErrorType.SYNC_DISCONNECTED}`: {
+ const win = event.target.ownerGlobal;
+ const { switchToTabHavingURI } =
+ win.docShell.chromeEventHandler.ownerGlobal;
+ switchToTabHavingURI(
+ "about:preferences?action=choose-what-to-sync#sync",
+ true,
+ {}
+ );
+ break;
+ }
+ }
+ }
+ if (event.type == "change") {
+ TabsSetupFlowManager.syncOpenTabs(event.target);
+ }
+ if (this.recentBrowsing && event.type === "fxview-search-textbox-query") {
+ this.onSearchQuery(event);
+ }
+ }
+
+ viewVisibleCallback() {
+ this.start();
+ }
+
+ viewHiddenCallback() {
+ this.stop();
+ }
+
+ onVisibilityChange() {
+ const isOpen = this.open;
+ const isVisible = this.isVisible;
+ if (isVisible && isOpen) {
+ this.update();
+ TabsSetupFlowManager.updateViewVisibility(this._id, "visible");
+ } else {
+ TabsSetupFlowManager.updateViewVisibility(
+ this._id,
+ isVisible ? "closed" : "hidden"
+ );
+ }
+
+ this.toggleVisibilityInCardContainer();
+ }
+
+ async observe(subject, topic, errorState) {
+ if (topic == TOPIC_SETUPSTATE_CHANGED) {
+ this.updateStates(errorState);
+ }
+ if (topic == SYNCED_TABS_CHANGED) {
+ this.getSyncedTabData();
+ }
+ }
+
+ updateStates(errorState) {
+ let stateIndex = TabsSetupFlowManager.uiStateIndex;
+ errorState = errorState || SyncedTabsErrorHandler.getErrorType();
+
+ if (stateIndex == 4 && this._currentSetupStateIndex !== stateIndex) {
+ // trigger an initial request for the synced tabs list
+ this.getSyncedTabData();
+ }
+
+ this._currentSetupStateIndex = stateIndex;
+ this.errorState = errorState;
+ }
+
+ actionMappings = {
+ "sign-in": {
+ header: "firefoxview-syncedtabs-signin-header",
+ description: "firefoxview-syncedtabs-signin-description",
+ buttonLabel: "firefoxview-syncedtabs-signin-primarybutton",
+ },
+ "add-device": {
+ header: "firefoxview-syncedtabs-adddevice-header",
+ description: "firefoxview-syncedtabs-adddevice-description",
+ buttonLabel: "firefoxview-syncedtabs-adddevice-primarybutton",
+ descriptionLink: {
+ name: "url",
+ url: "https://support.mozilla.org/kb/how-do-i-set-sync-my-computer#w_connect-additional-devices-to-sync",
+ },
+ },
+ "sync-tabs-disabled": {
+ header: "firefoxview-syncedtabs-synctabs-header",
+ description: "firefoxview-syncedtabs-synctabs-description",
+ buttonLabel: "firefoxview-tabpickup-synctabs-primarybutton",
+ },
+ loading: {
+ header: "firefoxview-syncedtabs-loading-header",
+ description: "firefoxview-syncedtabs-loading-description",
+ },
+ };
+
+ generateMessageCard({ error = false, action, errorState }) {
+ errorState = errorState || this.errorState;
+ let header,
+ description,
+ descriptionLink,
+ buttonLabel,
+ headerIconUrl,
+ mainImageUrl;
+ let descriptionArray;
+ if (error) {
+ let link;
+ ({ header, description, link, buttonLabel } =
+ SyncedTabsErrorHandler.getFluentStringsForErrorType(errorState));
+ action = `${errorState}`;
+ headerIconUrl = "chrome://global/skin/icons/info-filled.svg";
+ mainImageUrl =
+ "chrome://browser/content/firefoxview/synced-tabs-error.svg";
+ descriptionArray = [description];
+ if (errorState == "password-locked") {
+ descriptionLink = {};
+ // This is ugly, but we need to special case this link so we can
+ // coexist with the old view.
+ descriptionArray.push("firefoxview-syncedtab-password-locked-link");
+ descriptionLink.name = "syncedtab-password-locked-link";
+ descriptionLink.url = link.href;
+ }
+ } else {
+ header = this.actionMappings[action].header;
+ description = this.actionMappings[action].description;
+ buttonLabel = this.actionMappings[action].buttonLabel;
+ descriptionLink = this.actionMappings[action].descriptionLink;
+ mainImageUrl =
+ "chrome://browser/content/firefoxview/synced-tabs-error.svg";
+ descriptionArray = [description];
+ }
+
+ return html`
+ <fxview-empty-state
+ headerLabel=${header}
+ .descriptionLabels=${descriptionArray}
+ .descriptionLink=${ifDefined(descriptionLink)}
+ class="empty-state synced-tabs error"
+ ?isSelectedTab=${this.selectedTab}
+ ?isInnerCard=${this.recentBrowsing}
+ mainImageUrl="${ifDefined(mainImageUrl)}"
+ ?errorGrayscale=${error}
+ headerIconUrl="${ifDefined(headerIconUrl)}"
+ id="empty-container"
+ >
+ <button
+ class="primary"
+ slot="primary-action"
+ ?hidden=${!buttonLabel}
+ data-l10n-id="${ifDefined(buttonLabel)}"
+ data-action="${action}"
+ @click=${this.handleEvent}
+ aria-details="empty-container"
+ ></button>
+ </fxview-empty-state>
+ `;
+ }
+
+ onOpenLink(event) {
+ let currentWindow = this.getWindow();
+ if (currentWindow.openTrustedLinkIn) {
+ let where = lazy.BrowserUtils.whereToOpenLink(
+ event.detail.originalEvent,
+ false,
+ true
+ );
+ if (where == "current") {
+ where = "tab";
+ }
+ currentWindow.openTrustedLinkIn(event.originalTarget.url, where);
+ Services.telemetry.recordEvent(
+ "firefoxview_next",
+ "synced_tabs",
+ "tabs",
+ null,
+ {
+ page: this.recentBrowsing ? "recentbrowsing" : "syncedtabs",
+ }
+ );
+ }
+ if (this.searchQuery) {
+ const searchesHistogram = Services.telemetry.getKeyedHistogramById(
+ "FIREFOX_VIEW_CUMULATIVE_SEARCHES"
+ );
+ searchesHistogram.add(
+ this.recentBrowsing ? "recentbrowsing" : "syncedtabs",
+ this.cumulativeSearches
+ );
+ this.cumulativeSearches = 0;
+ }
+ }
+
+ onContextMenu(e) {
+ this.triggerNode = e.originalTarget;
+ e.target.querySelector("panel-list").toggle(e.detail.originalEvent);
+ }
+
+ panelListTemplate() {
+ return html`
+ <panel-list slot="menu" data-tab-type="syncedtabs">
+ <panel-item
+ @click=${this.openInNewWindow}
+ data-l10n-id="fxviewtabrow-open-in-window"
+ data-l10n-attrs="accesskey"
+ ></panel-item>
+ <panel-item
+ @click=${this.openInNewPrivateWindow}
+ data-l10n-id="fxviewtabrow-open-in-private-window"
+ data-l10n-attrs="accesskey"
+ ></panel-item>
+ <hr />
+ <panel-item
+ @click=${this.copyLink}
+ data-l10n-id="fxviewtabrow-copy-link"
+ data-l10n-attrs="accesskey"
+ ></panel-item>
+ </panel-list>
+ `;
+ }
+
+ noDeviceTabsTemplate(deviceName, deviceType, isSearchResultsEmpty = false) {
+ const template = html`<h3
+ slot=${ifDefined(this.recentBrowsing ? null : "header")}
+ class="device-header"
+ >
+ <span class="icon ${deviceType}" role="presentation"></span>
+ ${deviceName}
+ </h3>
+ ${when(
+ isSearchResultsEmpty,
+ () => html`
+ <div
+ slot=${ifDefined(this.recentBrowsing ? null : "main")}
+ class="blackbox notabs search-results-empty"
+ data-l10n-id="firefoxview-search-results-empty"
+ data-l10n-args=${JSON.stringify({
+ query: escapeHtmlEntities(this.searchQuery),
+ })}
+ ></div>
+ `,
+ () => html`
+ <div
+ slot=${ifDefined(this.recentBrowsing ? null : "main")}
+ class="blackbox notabs"
+ data-l10n-id="firefoxview-syncedtabs-device-notabs"
+ ></div>
+ `
+ )}`;
+ return this.recentBrowsing
+ ? template
+ : html`<card-container
+ shortPageName=${this.recentBrowsing ? "syncedtabs" : null}
+ >${template}</card-container
+ >`;
+ }
+
+ onSearchQuery(e) {
+ this.searchQuery = e.detail.query;
+ this.showAll = false;
+ }
+
+ deviceTemplate(deviceName, deviceType, tabItems) {
+ return html`<h3
+ slot=${!this.recentBrowsing ? "header" : null}
+ class="device-header"
+ >
+ <span class="icon ${deviceType}" role="presentation"></span>
+ ${deviceName}
+ </h3>
+ <fxview-tab-list
+ slot="main"
+ class="with-context-menu"
+ hasPopup="menu"
+ .tabItems=${ifDefined(tabItems)}
+ .searchQuery=${this.searchQuery}
+ maxTabsLength=${this.showAll ? -1 : this.maxTabsLength}
+ @fxview-tab-list-primary-action=${this.onOpenLink}
+ @fxview-tab-list-secondary-action=${this.onContextMenu}
+ >
+ ${this.panelListTemplate()}
+ </fxview-tab-list>`;
+ }
+
+ generateTabList() {
+ let renderArray = [];
+ let renderInfo = {};
+ for (let tab of this.currentSyncedTabs) {
+ if (!(tab.client in renderInfo)) {
+ renderInfo[tab.client] = {
+ name: tab.device,
+ deviceType: tab.deviceType,
+ tabs: [],
+ };
+ }
+ renderInfo[tab.client].tabs.push(tab);
+ }
+
+ // Add devices without tabs
+ for (let device of this.devices) {
+ if (!(device.id in renderInfo)) {
+ renderInfo[device.id] = {
+ name: device.name,
+ deviceType: device.clientType,
+ tabs: [],
+ };
+ }
+ }
+
+ for (let id in renderInfo) {
+ let tabItems = this.searchQuery
+ ? searchTabList(this.searchQuery, this.getTabItems(renderInfo[id].tabs))
+ : this.getTabItems(renderInfo[id].tabs);
+ if (tabItems.length) {
+ const template = this.recentBrowsing
+ ? this.deviceTemplate(
+ renderInfo[id].name,
+ renderInfo[id].deviceType,
+ tabItems
+ )
+ : html`<card-container
+ shortPageName=${this.recentBrowsing ? "syncedtabs" : null}
+ >${this.deviceTemplate(
+ renderInfo[id].name,
+ renderInfo[id].deviceType,
+ tabItems
+ )}
+ </card-container>`;
+ renderArray.push(template);
+ if (this.isShowAllLinkVisible(tabItems)) {
+ renderArray.push(html` <div class="show-all-link-container">
+ <div
+ class="show-all-link"
+ @click=${this.enableShowAll}
+ @keydown=${this.enableShowAll}
+ data-l10n-id="firefoxview-show-all"
+ tabindex="0"
+ role="link"
+ ></div>
+ </div>`);
+ }
+ } else {
+ // Check renderInfo[id].tabs.length to determine whether to display an
+ // empty tab list message or empty search results message.
+ // If there are no synced tabs, we always display the empty tab list
+ // message, even if there is an active search query.
+ renderArray.push(
+ this.noDeviceTabsTemplate(
+ renderInfo[id].name,
+ renderInfo[id].deviceType,
+ Boolean(renderInfo[id].tabs.length)
+ )
+ );
+ }
+ }
+ return renderArray;
+ }
+
+ isShowAllLinkVisible(tabItems) {
+ return (
+ this.recentBrowsing &&
+ this.searchQuery &&
+ tabItems.length > this.maxTabsLength &&
+ !this.showAll
+ );
+ }
+
+ enableShowAll(event) {
+ if (
+ event.type == "click" ||
+ (event.type == "keydown" && event.code == "Enter") ||
+ (event.type == "keydown" && event.code == "Space")
+ ) {
+ event.preventDefault();
+ this.showAll = true;
+ Services.telemetry.recordEvent(
+ "firefoxview_next",
+ "search_show_all",
+ "showallbutton",
+ null,
+ {
+ section: "syncedtabs",
+ }
+ );
+ }
+ }
+
+ generateCardContent() {
+ switch (this._currentSetupStateIndex) {
+ case 0 /* error-state */:
+ if (this.errorState) {
+ return this.generateMessageCard({ error: true });
+ }
+ return this.generateMessageCard({ action: "loading" });
+ case 1 /* not-signed-in */:
+ if (Services.prefs.prefHasUserValue("services.sync.lastversion")) {
+ // If this pref is set, the user has signed out of sync.
+ // This path is also taken if we are disconnected from sync. See bug 1784055
+ return this.generateMessageCard({
+ error: true,
+ errorState: "signed-out",
+ });
+ }
+ return this.generateMessageCard({ action: "sign-in" });
+ case 2 /* connect-secondary-device*/:
+ return this.generateMessageCard({ action: "add-device" });
+ case 3 /* disabled-tab-sync */:
+ return this.generateMessageCard({ action: "sync-tabs-disabled" });
+ case 4 /* synced-tabs-loaded*/:
+ // There seems to be an edge case where sync says everything worked
+ // fine but we have no devices.
+ if (!this.devices.length) {
+ return this.generateMessageCard({ action: "add-device" });
+ }
+ return this.generateTabList();
+ }
+ return html``;
+ }
+
+ render() {
+ this.open =
+ !TabsSetupFlowManager.isTabSyncSetupComplete ||
+ Services.prefs.getBoolPref(UI_OPEN_STATE, true);
+
+ let renderArray = [];
+ renderArray.push(html` <link
+ rel="stylesheet"
+ href="chrome://browser/content/firefoxview/view-syncedtabs.css"
+ />`);
+ renderArray.push(html` <link
+ rel="stylesheet"
+ href="chrome://browser/content/firefoxview/firefoxview.css"
+ />`);
+
+ if (!this.recentBrowsing) {
+ renderArray.push(html`<div class="sticky-container bottom-fade">
+ <h2
+ class="page-header"
+ data-l10n-id="firefoxview-synced-tabs-header"
+ ></h2>
+ ${when(
+ isSearchEnabled() || this._currentSetupStateIndex === 4,
+ () => html`<div class="syncedtabs-header">
+ ${when(
+ isSearchEnabled(),
+ () => html`<div>
+ <fxview-search-textbox
+ data-l10n-id="firefoxview-search-text-box-syncedtabs"
+ data-l10n-attrs="placeholder"
+ @fxview-search-textbox-query=${this.onSearchQuery}
+ .size=${this.searchTextboxSize}
+ pageName=${this.recentBrowsing
+ ? "recentbrowsing"
+ : "syncedtabs"}
+ ></fxview-search-textbox>
+ </div>`
+ )}
+ ${when(
+ this._currentSetupStateIndex === 4,
+ () => html`
+ <button
+ class="small-button"
+ data-action="add-device"
+ @click=${this.handleEvent}
+ >
+ <img
+ class="icon"
+ role="presentation"
+ src="chrome://global/skin/icons/plus.svg"
+ alt="plus sign"
+ /><span
+ data-l10n-id="firefoxview-syncedtabs-connect-another-device"
+ data-action="add-device"
+ ></span>
+ </button>
+ `
+ )}
+ </div>`
+ )}
+ </div>`);
+ }
+
+ if (this.recentBrowsing) {
+ renderArray.push(
+ html`<card-container
+ preserveCollapseState
+ shortPageName="syncedtabs"
+ ?showViewAll=${this._currentSetupStateIndex == 4 &&
+ this.currentSyncedTabs.length}
+ ?isEmptyState=${!this.currentSyncedTabs.length}
+ >
+ >
+ <h3
+ slot="header"
+ data-l10n-id="firefoxview-synced-tabs-header"
+ class="recentbrowsing-header"
+ ></h3>
+ <div slot="main">${this.generateCardContent()}</div>
+ </card-container>`
+ );
+ } else {
+ renderArray.push(
+ html`<div class="cards-container">${this.generateCardContent()}</div>`
+ );
+ }
+ return renderArray;
+ }
+
+ async onReload() {
+ await TabsSetupFlowManager.syncOnPageReload();
+ }
+
+ getTabItems(tabs) {
+ tabs = tabs || this.tabs;
+ return tabs?.map(tab => ({
+ icon: tab.icon,
+ title: tab.title,
+ time: tab.lastUsed * 1000,
+ url: tab.url,
+ primaryL10nId: "firefoxview-tabs-list-tab-button",
+ primaryL10nArgs: JSON.stringify({ targetURI: tab.url }),
+ secondaryL10nId: "fxviewtabrow-options-menu-button",
+ secondaryL10nArgs: JSON.stringify({ tabTitle: tab.title }),
+ }));
+ }
+
+ updateTabsList(syncedTabs) {
+ if (!syncedTabs.length) {
+ this.currentSyncedTabs = syncedTabs;
+ this.sendTabTelemetry(0);
+ }
+
+ const tabsToRender = syncedTabs;
+
+ // Return early if new tabs are the same as previous ones
+ if (
+ JSON.stringify(tabsToRender) == JSON.stringify(this.currentSyncedTabs)
+ ) {
+ return;
+ }
+
+ this.currentSyncedTabs = tabsToRender;
+ // Record the full tab count
+ this.sendTabTelemetry(syncedTabs.length);
+ }
+
+ async getSyncedTabData() {
+ this.devices = await lazy.SyncedTabs.getTabClients();
+ let tabs = await lazy.SyncedTabs.createRecentTabsList(this.devices, 50, {
+ removeAllDupes: false,
+ removeDeviceDupes: true,
+ });
+
+ this.updateTabsList(tabs);
+ }
+
+ updated() {
+ this.fullyUpdated = true;
+ this.toggleVisibilityInCardContainer();
+ }
+
+ sendTabTelemetry(numTabs) {
+ /*
+ Services.telemetry.recordEvent(
+ "firefoxview_next",
+ "synced_tabs",
+ "tabs",
+ null,
+ {
+ count: numTabs.toString(),
+ }
+ );
+*/
+ }
+}
+customElements.define("view-syncedtabs", SyncedTabsInView);
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..3fd2bf95e3
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/FirefoxViewTestUtils.sys.mjs
@@ -0,0 +1,177 @@
+/* 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";
+
+var testScope;
+
+/**
+ * Module consumers can optionally initialize the module
+ *
+ * @param {object} scope
+ * object with SimpleTest and info properties.
+ */
+function init(scope) {
+ testScope = scope;
+}
+
+function getFirefoxViewURL() {
+ return "about:firefoxview";
+}
+
+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) {
+ if (!testScope?.SimpleTest) {
+ throw new Error(
+ "Must initialize FirefoxViewTestUtils with a test scope which has a SimpleTest property"
+ );
+ }
+ await testScope.SimpleTest.promiseFocus(win);
+ let fxviewTab = win.FirefoxViewHandler.tab;
+ let alreadyLoaded =
+ fxviewTab?.linkedBrowser.currentURI.spec.includes(getFirefoxViewURL()) &&
+ fxviewTab?.linkedBrowser?.contentDocument?.readyState == "complete";
+ let enteredPromise = alreadyLoaded
+ ? Promise.resolve()
+ : TestUtils.topicObserved("firefoxview-entered");
+
+ if (!fxviewTab?.selected) {
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#firefox-view-button",
+ { type: "mousedown" },
+ win.browsingContext
+ );
+ await TestUtils.waitForTick();
+ }
+
+ fxviewTab = win.FirefoxViewHandler.tab;
+ assertFirefoxViewTab(win);
+ Assert.ok(
+ win.FirefoxViewHandler.tab.selected,
+ "Firefox View tab is selected"
+ );
+
+ testScope.info(
+ "openFirefoxViewTab, waiting for complete readyState, visible and firefoxview-entered"
+ );
+ await Promise.all([
+ TestUtils.waitForCondition(() => {
+ const document = fxviewTab.linkedBrowser.contentDocument;
+ return (
+ document.readyState == "complete" &&
+ document.visibilityState == "visible"
+ );
+ }),
+ enteredPromise,
+ ]);
+ testScope.info("openFirefoxViewTab, ready resolved");
+ return fxviewTab;
+}
+
+function closeFirefoxViewTab(win) {
+ if (win.FirefoxViewHandler.tab) {
+ 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 {Window} [options.win]
+ * The window in which to run the task.
+ * @param {(MozBrowser) => any} taskFn
+ * The task to run. It can be asynchronous.
+ * @returns {any}
+ * The value returned by the task.
+ */
+async function withFirefoxView(
+ { openNewWindow = false, resetFlowManager = true, win = null },
+ taskFn
+) {
+ if (!win) {
+ 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 == getFirefoxViewURL();
+}
+
+export {
+ init,
+ withFirefoxView,
+ assertFirefoxViewTab,
+ assertFirefoxViewTabSelected,
+ openFirefoxViewTab,
+ closeFirefoxViewTab,
+ isFirefoxViewTabSelectedInWindow,
+ getFirefoxViewURL,
+};
diff --git a/browser/components/firefoxview/tests/browser/browser.toml b/browser/components/firefoxview/tests/browser/browser.toml
new file mode 100644
index 0000000000..8e2005760b
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser.toml
@@ -0,0 +1,74 @@
+[DEFAULT]
+support-files = ["head.js"]
+prefs = [
+ "browser.sessionstore.closedTabsFromAllWindows=true",
+ "browser.sessionstore.closedTabsFromClosedWindows=true",
+ "browser.tabs.firefox-view.logLevel=All",
+]
+
+["browser_dragDrop_after_opening_fxViewTab.js"]
+
+["browser_entrypoint_management.js"]
+
+["browser_feature_callout.js"]
+skip-if = ["true"] # Bug 1869605 and # Bug 1870296
+
+["browser_feature_callout_position.js"]
+skip-if = ["true"] # Bug 1869605 and # Bug 1870296
+
+["browser_feature_callout_resize.js"]
+skip-if = ["true"] # Bug 1869605 and # Bug 1870296
+
+["browser_feature_callout_targeting.js"]
+skip-if = ["true"] # Bug 1869605 and # Bug 1870296
+
+["browser_feature_callout_theme.js"]
+skip-if = ["true"] # Bug 1869605 and # Bug 1870296
+
+["browser_firefoxview.js"]
+
+["browser_firefoxview_tab.js"]
+
+["browser_notification_dot.js"]
+skip-if = ["true"] # Bug 1851453
+
+["browser_opentabs_changes.js"]
+
+["browser_reload_firefoxview.js"]
+
+["browser_tab_close_last_tab.js"]
+
+["browser_tab_on_close_warning.js"]
+
+["browser_firefoxview_paused.js"]
+
+["browser_firefoxview_general_telemetry.js"]
+
+["browser_firefoxview_navigation.js"]
+
+["browser_firefoxview_search_telemetry.js"]
+
+["browser_firefoxview_virtual_list.js"]
+
+["browser_history_firefoxview.js"]
+skip-if = ["true"] # Bug 1877594
+
+["browser_opentabs_firefoxview.js"]
+
+["browser_opentabs_cards.js"]
+fail-if = ["a11y_checks"] # Bugs 1858041, 1854625, and 1872174 clicked Show all link is not accessible because it is "hidden" when clicked
+
+["browser_opentabs_recency.js"]
+skip-if = [
+ "os == 'win'",
+ "os == 'mac' && verify",
+ "os == 'linux'"
+] # macos times out, see bug 1857293, skipped for windows, see bug 1858460, skipped for linux, see bug 1875877
+
+["browser_opentabs_tab_indicators.js"]
+
+["browser_recentlyclosed_firefoxview.js"]
+
+["browser_syncedtabs_errors_firefoxview.js"]
+
+["browser_syncedtabs_firefoxview.js"]
diff --git a/browser/components/firefoxview/tests/browser/browser_dragDrop_after_opening_fxViewTab.js b/browser/components/firefoxview/tests/browser/browser_dragDrop_after_opening_fxViewTab.js
new file mode 100644
index 0000000000..9ce547238a
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_dragDrop_after_opening_fxViewTab.js
@@ -0,0 +1,120 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests that dragging and dropping tabs into tabbrowser works as intended
+ * after opening the Firefox View tab for RTL builds. There was an issue where
+ * tabs from dragged links were not dropped in the correct tab indexes
+ * for RTL builds because logic for RTL builds did not take into consideration
+ * hidden tabs like the Firefox View tab. This test makes sure that this behavior does not reoccur.
+ */
+add_task(async function () {
+ info("Setting browser to RTL locale");
+ await SpecialPowers.pushPrefEnv({ set: [["intl.l10n.pseudo", "bidi"]] });
+
+ // window.RTL_UI doesn't update in existing windows when this pref is changed,
+ // so we need to test in a new window.
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+
+ const TEST_ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ );
+ let newTab = win.gBrowser.tabs[0];
+
+ let waitForTestTabPromise = BrowserTestUtils.waitForNewTab(
+ win.gBrowser,
+ TEST_ROOT + "file_new_tab_page.html"
+ );
+ let testTab = await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ TEST_ROOT + "file_new_tab_page.html"
+ );
+ await waitForTestTabPromise;
+
+ let linkSrcEl = win.document.querySelector("a");
+ ok(linkSrcEl, "Link exists");
+
+ let dropPromise = BrowserTestUtils.waitForEvent(
+ win.gBrowser.tabContainer,
+ "drop"
+ );
+
+ /**
+ * There should be 2 tabs:
+ * 1. new tab (auto-generated)
+ * 2. test tab
+ */
+ is(win.gBrowser.visibleTabs.length, 2, "There should be 2 tabs");
+
+ // Now open Firefox View tab
+ info("Opening Firefox View tab");
+ await openFirefoxViewTab(win);
+
+ /**
+ * There should be 2 visible tabs:
+ * 1. new tab (auto-generated)
+ * 2. test tab
+ * Firefox View tab is hidden.
+ */
+ is(
+ win.gBrowser.visibleTabs.length,
+ 2,
+ "There should still be 2 visible tabs after opening Firefox View tab"
+ );
+
+ info("Switching to test tab");
+ await BrowserTestUtils.switchTab(win.gBrowser, testTab);
+
+ let waitForDraggedTabPromise = BrowserTestUtils.waitForNewTab(
+ win.gBrowser,
+ "https://example.com/#test"
+ );
+
+ info("Dragging link between test tab and new tab");
+ EventUtils.synthesizeDrop(
+ linkSrcEl,
+ testTab,
+ [[{ type: "text/plain", data: "https://example.com/#test" }]],
+ "link",
+ win,
+ win,
+ {
+ clientX: testTab.getBoundingClientRect().right,
+ }
+ );
+
+ info("Waiting for drop event");
+ await dropPromise;
+ info("Waiting for dragged tab to be created");
+ let draggedTab = await waitForDraggedTabPromise;
+
+ /**
+ * There should be 3 visible tabs:
+ * 1. new tab (auto-generated)
+ * 2. new tab from dragged link
+ * 3. test tab
+ *
+ * In RTL build, it should appear in the following order:
+ * <test tab> <link dragged tab> <new tab> | <FxView tab>
+ */
+ is(win.gBrowser.visibleTabs.length, 3, "There should be 3 tabs");
+ is(
+ win.gBrowser.visibleTabs.indexOf(newTab),
+ 0,
+ "New tab should still be rightmost visible tab"
+ );
+ is(
+ win.gBrowser.visibleTabs.indexOf(draggedTab),
+ 1,
+ "Dragged link should positioned at new index"
+ );
+ is(
+ win.gBrowser.visibleTabs.indexOf(testTab),
+ 2,
+ "Test tab should be to the left of dragged tab"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_entrypoint_management.js b/browser/components/firefoxview/tests/browser/browser_entrypoint_management.js
new file mode 100644
index 0000000000..ef6b0c99f5
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_entrypoint_management.js
@@ -0,0 +1,67 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_removing_button_should_close_tab() {
+ await withFirefoxView({}, async browser => {
+ let win = browser.ownerGlobal;
+ let tab = browser.getTabBrowser().getTabForBrowser(browser);
+ let button = win.document.getElementById("firefox-view-button");
+ await win.gCustomizeMode.removeFromArea(button, "toolbar-context-menu");
+ ok(!tab.isConnected, "Tab should have been removed.");
+ isnot(win.gBrowser.selectedTab, tab, "A different tab should be selected.");
+ });
+ CustomizableUI.reset();
+});
+
+add_task(async function test_button_auto_readd() {
+ await withFirefoxView({}, async browser => {
+ let { FirefoxViewHandler } = browser.ownerGlobal;
+
+ CustomizableUI.removeWidgetFromArea("firefox-view-button");
+ ok(
+ !CustomizableUI.getPlacementOfWidget("firefox-view-button"),
+ "Button has no placement"
+ );
+ ok(!FirefoxViewHandler.tab, "Shouldn't have tab reference");
+ ok(!FirefoxViewHandler.button, "Shouldn't have button reference");
+
+ FirefoxViewHandler.openTab();
+ ok(FirefoxViewHandler.tab, "Tab re-opened");
+ ok(FirefoxViewHandler.button, "Button re-added");
+ let placement = CustomizableUI.getPlacementOfWidget("firefox-view-button");
+ is(
+ placement.area,
+ CustomizableUI.AREA_TABSTRIP,
+ "Button re-added to the tabs toolbar"
+ );
+ is(placement.position, 0, "Button re-added as the first toolbar element");
+ });
+ CustomizableUI.reset();
+});
+
+add_task(async function test_button_moved() {
+ await withFirefoxView({}, async browser => {
+ let { FirefoxViewHandler } = browser.ownerGlobal;
+ CustomizableUI.addWidgetToArea(
+ "firefox-view-button",
+ CustomizableUI.AREA_NAVBAR,
+ 0
+ );
+ is(
+ FirefoxViewHandler.button.closest("toolbar").id,
+ "nav-bar",
+ "Button is in the navigation toolbar"
+ );
+ });
+ await withFirefoxView({}, async browser => {
+ let { FirefoxViewHandler } = browser.ownerGlobal;
+ is(
+ FirefoxViewHandler.button.closest("toolbar").id,
+ "nav-bar",
+ "Button remains in the navigation toolbar"
+ );
+ });
+ CustomizableUI.reset();
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_feature_callout.js b/browser/components/firefoxview/tests/browser/browser_feature_callout.js
new file mode 100644
index 0000000000..3fd2ee517d
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_feature_callout.js
@@ -0,0 +1,746 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+const { MessageLoaderUtils } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/ASRouter.sys.mjs"
+);
+
+const { BuiltInThemes } = ChromeUtils.importESModule(
+ "resource:///modules/BuiltInThemes.sys.mjs"
+);
+
+const defaultPrefValue = getPrefValueByScreen(1);
+
+add_setup(async function () {
+ requestLongerTimeout(3);
+ 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;
+
+ launchFeatureTourIn(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, '{"screen":"","complete":true}']],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(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;
+ launchFeatureTourIn(tab1.linkedBrowser.contentWindow);
+ 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;
+ launchFeatureTourIn(tab2.linkedBrowser.contentWindow);
+ 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 clickCTA(tab2Doc);
+ await waitForCalloutScreen(tab2Doc, "FEATURE_CALLOUT_2");
+
+ 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 clickCTA(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() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[featureTourPref, '{"screen":"FEATURE_CALLOUT_2","complete":false}']],
+ });
+ const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+ const spy = new TelemetrySpy(sandbox);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(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_arrow_position_attribute_exists() {
+ const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ const callout = await BrowserTestUtils.waitForCondition(
+ () =>
+ document.querySelector(`${calloutSelector}[arrow-position="top"]`),
+ "Waiting for callout to render"
+ );
+ is(
+ callout.getAttribute("arrow-position"),
+ "top",
+ "Arrow position attribute 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");
+ testMessage.message.content.screens[0].anchors[0].arrow_position = "start";
+ testMessage.message.content.screens[0].anchors[0].selector =
+ "span.brand-feature-name";
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ const callout = await BrowserTestUtils.waitForCondition(
+ () =>
+ document.querySelector(
+ `${calloutSelector}[arrow-position="inline-start"]:not(.hidden)`
+ ),
+ "Waiting for callout to render"
+ );
+ is(
+ callout.getAttribute("arrow-position"),
+ "inline-start",
+ "Feature callout has inline-start arrow position when arrow_position is set to 'start'"
+ );
+ ok(
+ !callout.classList.contains("hidden"),
+ "Feature Callout is not hidden"
+ );
+ }
+ );
+ sandbox.restore();
+});
+
+add_task(async function feature_callout_respects_cfr_features_pref() {
+ async function toggleCFRFeaturesPref(value) {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features",
+ value,
+ ],
+ ],
+ });
+ }
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[featureTourPref, defaultPrefValue]],
+ });
+
+ await toggleCFRFeaturesPref(true);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+ ok(
+ document.querySelector(calloutSelector),
+ "Feature Callout element exists"
+ );
+ }
+ );
+
+ await SpecialPowers.popPrefEnv();
+ await toggleCFRFeaturesPref(false);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ ok(
+ !document.querySelector(calloutSelector),
+ "Feature Callout element was not created because CFR pref was disabled"
+ );
+ }
+ );
+
+ await SpecialPowers.popPrefEnv();
+});
+
+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;
+
+ launchFeatureTourIn(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 clickCTA(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_timeout() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[featureTourPref, `{"screen":"","complete":true}`]],
+ });
+ const screenId = "FIREFOX_VIEW_TAB_PICKUP_REMINDER";
+ 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: "timeout", options: { once: true, interval: 5000 } },
+ action: { dismiss: true, type: "CANCEL" },
+ },
+ ];
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+ const telemetrySpy = new TelemetrySpy(sandbox);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ let onInterval;
+ let startedInterval = new Promise(resolve => {
+ sandbox
+ .stub(browser.contentWindow, "setInterval")
+ .callsFake((fn, ms) => {
+ Assert.strictEqual(
+ ms,
+ 5000,
+ "setInterval called with 5 second interval"
+ );
+ onInterval = fn;
+ resolve();
+ return 1;
+ });
+ });
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ info("Waiting for callout to render");
+ await startedInterval;
+ await waitForCalloutScreen(document, screenId);
+
+ info("Ending timeout");
+ onInterval();
+ await waitForCalloutRemoved(document);
+
+ // Test that appropriate telemetry is sent
+ telemetrySpy.assertCalledWith({
+ event: "PAGE_EVENT",
+ event_context: {
+ action: "CANCEL",
+ reason: "TIMEOUT",
+ source: "timeout",
+ page: "about:firefoxview",
+ },
+ message_id: screenId,
+ });
+ telemetrySpy.assertCalledWith({
+ event: "DISMISS",
+ event_context: {
+ source: "PAGE_EVENT:timeout",
+ page: "about:firefoxview",
+ },
+ message_id: screenId,
+ });
+ }
+ );
+ Services.prefs.clearUserPref("browser.firefox-view.view-count");
+ sandbox.restore();
+ ASRouter.resetMessageState();
+});
+
+add_task(async function feature_callout_advance_tour_on_page_click() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ featureTourPref,
+ JSON.stringify({
+ screen: "FEATURE_CALLOUT_1",
+ complete: false,
+ }),
+ ],
+ ],
+ });
+
+ // Add page action listeners to the built-in messages.
+ let testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
+ // Configure message with a dismiss action on tab container click
+ testMessage.message.content.screens.forEach(screen => {
+ screen.content.page_event_listeners = [
+ {
+ params: { type: "click", selectors: ".brand-logo" },
+ action: JSON.parse(
+ JSON.stringify(screen.content.primary_button.action)
+ ),
+ },
+ ];
+ });
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+ info("Clicking page container");
+ // We intentionally turn off a11y_checks, because the following click
+ // is send to dismiss the feature callout using an alternative way of
+ // the callout dismissal, where other ways are accessible, therefore
+ // this test can be ignored.
+ AccessibilityUtils.setEnv({
+ mustHaveAccessibleRule: false,
+ });
+ document.querySelector(".brand-logo").click();
+ AccessibilityUtils.resetEnv();
+
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_2");
+ info("Clicking page container");
+ // We intentionally turn off a11y_checks, because the following click
+ // is send to dismiss the feature callout using an alternative way of
+ // the callout dismissal, where other ways are accessible, therefore
+ // this test can be ignored.
+ AccessibilityUtils.setEnv({
+ mustHaveAccessibleRule: false,
+ });
+ document.querySelector(".brand-logo").click();
+ AccessibilityUtils.resetEnv();
+
+ 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();
+ ASRouter.resetMessageState();
+});
+
+add_task(async function feature_callout_dismiss_on_escape() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[featureTourPref, `{"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;
+
+ launchFeatureTourIn(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 => {
+ launchFeatureTourIn(browser.contentWindow);
+
+ 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");
+
+ const { document } = browser.contentWindow;
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+ ok(
+ document.querySelector(calloutSelector),
+ "Feature Callout element exists"
+ );
+ info("Feature tour started");
+ await clickCTA(document);
+ }
+ );
+
+ ok(remoteSettingsStub.called, "Tried to load CFR messages");
+ sandbox.restore();
+ await SpecialPowers.popPrefEnv();
+ ASRouter.resetMessageState();
+});
+
+add_task(async function feature_callout_returns_default_fxview_focus_to_top() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[featureTourPref, defaultPrefValue]],
+ });
+ const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+
+ ok(
+ document.querySelector(calloutSelector),
+ "Feature Callout element exists"
+ );
+
+ document.querySelector(".dismiss-button").click();
+ await waitForCalloutRemoved(document);
+
+ Assert.strictEqual(
+ document.activeElement.localName,
+ "body",
+ "by default focus returns to the document body after callout closes"
+ );
+ }
+ );
+ sandbox.restore();
+ await SpecialPowers.popPrefEnv();
+ ASRouter.resetMessageState();
+});
+
+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;
+
+ launchFeatureTourIn(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
+ Assert.strictEqual(
+ 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() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[featureTourPref, defaultPrefValue]],
+ });
+ const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
+ testMessage.message.content.screens[0].anchors[0].hide_arrow = true;
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+
+ is(
+ getComputedStyle(
+ document.querySelector(`${calloutSelector} .arrow-box`)
+ ).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..fcb66719d9
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_feature_callout_position.js
@@ -0,0 +1,445 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+requestLongerTimeout(2);
+
+const defaultPrefValue = getPrefValueByScreen(1);
+
+const squareWidth = 24;
+const arrowWidth = Math.hypot(squareWidth, squareWidth);
+const arrowHeight = arrowWidth / 2;
+let overlap = 5 - arrowHeight;
+
+add_task(
+ async function feature_callout_first_screen_positioned_below_element() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[featureTourPref, defaultPrefValue]],
+ });
+ const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+ let parentBottom = document
+ .querySelector("#tab-pickup-container")
+ .getBoundingClientRect().bottom;
+ let containerTop = document
+ .querySelector(calloutSelector)
+ .getBoundingClientRect().top;
+
+ isfuzzy(
+ parentBottom - containerTop,
+ overlap,
+ 1, // add 1px fuzziness to account for possible subpixel rounding
+ "Feature Callout is positioned below parent element with the arrow overlapping by 5px"
+ );
+ }
+ );
+ sandbox.restore();
+ }
+);
+
+add_task(
+ async function feature_callout_second_screen_positioned_right_of_element() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[featureTourPref, getPrefValueByScreen(2)]],
+ });
+ const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
+ testMessage.message.content.screens[1].anchors = [
+ { selector: ".brand-logo", arrow_position: "start" },
+ ];
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_2");
+
+ let parentRight = document
+ .querySelector(".brand-logo")
+ .getBoundingClientRect().right;
+ let containerLeft = document
+ .querySelector(calloutSelector)
+ .getBoundingClientRect().left;
+ isfuzzy(
+ parentRight - containerLeft,
+ overlap,
+ 1,
+ "Feature Callout is positioned right of parent element with the arrow overlapping by 5px"
+ );
+ }
+ );
+ sandbox.restore();
+ }
+);
+
+add_task(
+ async function feature_callout_second_screen_positioned_above_element() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[featureTourPref, getPrefValueByScreen(2)]],
+ });
+ const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(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"],
+ [featureTourPref, getPrefValueByScreen(2)],
+ ],
+ });
+
+ const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(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() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[featureTourPref, defaultPrefValue]],
+ });
+ const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(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() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[featureTourPref, defaultPrefValue]],
+ });
+ const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
+ testMessage.message.content.screens[0].anchors[0].arrow_position = "top-end";
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(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;
+
+ is(
+ container.getAttribute("arrow-position"),
+ "top-end",
+ "Feature Callout container has the expected top-end arrow-position attribute"
+ );
+ 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() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[featureTourPref, defaultPrefValue]],
+ });
+ const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
+ testMessage.message.content.screens[0].anchors[0].arrow_position =
+ "top-start";
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(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;
+
+ is(
+ container.getAttribute("arrow-position"),
+ "top-start",
+ "Feature Callout container has the expected top-start arrow-position attribute"
+ );
+ 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"],
+ [featureTourPref, defaultPrefValue],
+ ],
+ });
+
+ const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
+ testMessage.message.content.screens[0].anchors[0].arrow_position =
+ "top-end";
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(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;
+
+ is(
+ container.getAttribute("arrow-position"),
+ "top-start",
+ "In RTL mode, the feature callout container has the expected top-start arrow-position attribute"
+ );
+ 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",
+ template: "feature_callout",
+ content: {
+ id: "FIREFOX_VIEW_FEATURE_TOUR",
+ transitions: false,
+ disableHistoryUpdates: true,
+ screens: [
+ {
+ id: "FEATURE_CALLOUT_1",
+ anchors: [
+ { selector: ".brand-feature-name", arrow_position: "end" },
+ ],
+ content: {
+ position: "callout",
+ 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-feature-name has a height of 32px
+ },
+ dismiss_button: {
+ action: {
+ navigate: true,
+ },
+ },
+ },
+ },
+ ],
+ },
+ },
+ };
+
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[featureTourPref, defaultPrefValue]],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+ let parent = document.querySelector(".brand-feature-name");
+ 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..cbc0547717
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_feature_callout_resize.js
@@ -0,0 +1,178 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function getArrowPosition(doc) {
+ let callout = doc.querySelector(calloutSelector);
+ return callout.getAttribute("arrow-position");
+}
+
+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");
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:firefoxview" },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ browser.contentWindow.resizeTo(1550, 1000);
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+ await BrowserTestUtils.waitForCondition(() => {
+ if (getArrowPosition(document) === "top") {
+ return true;
+ }
+ browser.contentWindow.resizeTo(1550, 1000);
+ return false;
+ });
+ is(
+ getArrowPosition(document),
+ "top",
+ "On first screen at 1550x1000, the callout is positioned below the parent element"
+ );
+
+ let startingTop = document.querySelector(calloutSelector).style.top;
+ browser.contentWindow.resizeTo(1800, 400);
+ // Wait for callout to be repositioned
+ await BrowserTestUtils.waitForMutationCondition(
+ document.querySelector(calloutSelector),
+ { attributeFilter: ["style"], attributes: true },
+ () => document.querySelector(calloutSelector).style.top != startingTop
+ );
+ await BrowserTestUtils.waitForCondition(() => {
+ if (getArrowPosition(document) === "inline-start") {
+ return true;
+ }
+ browser.contentWindow.resizeTo(1800, 400);
+ return false;
+ });
+ is(
+ getArrowPosition(document),
+ "inline-start",
+ "On first screen at 1800x400, 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
+ );
+ await BrowserTestUtils.waitForCondition(() => {
+ if (getArrowPosition(document) === "top") {
+ return true;
+ }
+ browser.contentWindow.resizeTo(1100, 600);
+ return false;
+ });
+ is(
+ getArrowPosition(document),
+ "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");
+ const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:firefoxview" },
+ async browser => {
+ const { document } = browser.contentWindow;
+
+ launchFeatureTourIn(browser.contentWindow);
+
+ browser.contentWindow.resizeTo(1550, 1000);
+ await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
+ await BrowserTestUtils.waitForCondition(() => {
+ if (getArrowPosition(document) === "top") {
+ return true;
+ }
+ browser.contentWindow.resizeTo(1550, 1000);
+ return false;
+ });
+ is(
+ getArrowPosition(document),
+ "top",
+ "On first screen at 1550x1000, the callout is positioned below the parent element"
+ );
+
+ let startingTop = document.querySelector(calloutSelector).style.top;
+ browser.contentWindow.resizeTo(1800, 400);
+ // Wait for callout to be repositioned
+ await BrowserTestUtils.waitForMutationCondition(
+ document.querySelector(calloutSelector),
+ { attributeFilter: ["style"], attributes: true },
+ () => document.querySelector(calloutSelector).style.top != startingTop
+ );
+ await BrowserTestUtils.waitForCondition(() => {
+ if (getArrowPosition(document) === "inline-end") {
+ return true;
+ }
+ browser.contentWindow.resizeTo(1800, 400);
+ return false;
+ });
+ is(
+ getArrowPosition(document),
+ "inline-end",
+ "On first screen at 1800x400, 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
+ );
+ await BrowserTestUtils.waitForCondition(() => {
+ if (getArrowPosition(document) === "top") {
+ return true;
+ }
+ browser.contentWindow.resizeTo(1100, 600);
+ return false;
+ });
+ is(
+ getArrowPosition(document),
+ "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..a4f9c6b65e
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_feature_callout_targeting.js
@@ -0,0 +1,175 @@
+"use strict";
+
+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;
+
+ launchFeatureTourIn(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;
+
+ launchFeatureTourIn(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;
+
+ launchFeatureTourIn(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;
+
+ launchFeatureTourIn(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..f5fd77e4ad
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_feature_callout_theme.js
@@ -0,0 +1,80 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { FeatureCallout } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/FeatureCallout.sys.mjs"
+);
+
+async function testCallout(config) {
+ const featureCallout = new FeatureCallout(config);
+ const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
+ const screen = testMessage.message.content.screens[1];
+ screen.anchors[0].selector = "body";
+ testMessage.message.content.screens = [screen];
+ featureCallout.showFeatureCallout(testMessage.message);
+ await waitForCalloutScreen(config.win.document, screen.id);
+ testStyles(config);
+ return { featureCallout };
+}
+
+function testStyles({ win, theme }) {
+ const calloutEl = win.document.querySelector(calloutSelector);
+ const calloutStyle = win.getComputedStyle(calloutEl);
+ for (const type of ["light", "dark", "hcm"]) {
+ const appliedTheme = Object.assign(
+ {},
+ FeatureCallout.themePresets[theme.preset],
+ theme
+ );
+ const scheme = appliedTheme[type];
+ for (const name of FeatureCallout.themePropNames) {
+ Assert.equal(
+ !!calloutStyle.getPropertyValue(`--fc-${name}-${type}`),
+ !!(scheme?.[name] || appliedTheme.all?.[name]),
+ `Theme property --fc-${name}-${type} is set`
+ );
+ }
+ }
+}
+
+add_task(async function feature_callout_chrome_theme() {
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ await testCallout({
+ win,
+ location: "chrome",
+ context: "chrome",
+ browser: win.gBrowser.selectedBrowser,
+ theme: { preset: "chrome" },
+ });
+ await BrowserTestUtils.closeWindow(win);
+});
+
+add_task(async function feature_callout_pdfjs_theme() {
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ await testCallout({
+ win,
+ location: "pdfjs",
+ context: "chrome",
+ browser: win.gBrowser.selectedBrowser,
+ theme: { preset: "pdfjs", simulateContent: true },
+ });
+ await BrowserTestUtils.closeWindow(win);
+});
+
+add_task(async function feature_callout_content_theme() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:firefoxview",
+ },
+ browser =>
+ testCallout({
+ win: browser.contentWindow,
+ location: "about:firefoxview",
+ context: "content",
+ theme: { preset: "themed-content" },
+ })
+ );
+});
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..33467941a4
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_firefoxview.js
@@ -0,0 +1,87 @@
+/* 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.querySelector("fxview-category-navigation"),
+ "fxview-category-navigation element exists"
+ );
+ ok(document.querySelector("named-deck"), "named-deck element exists");
+ });
+});
+
+add_task(async function test_aria_roles() {
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ is(document.location.href, "about:firefoxview");
+
+ is(
+ document.querySelector("main").getAttribute("role"),
+ "application",
+ "The main element has role='application'"
+ );
+ // Purge session history to ensure recently closed empty state is shown
+ Services.obs.notifyObservers(null, "browser:purge-session-history");
+ let recentlyClosedComponent = document.querySelector(
+ "view-recentlyclosed[slot=recentlyclosed]"
+ );
+ await TestUtils.waitForCondition(
+ () => recentlyClosedComponent.fullyUpdated
+ );
+ let recentlyClosedEmptyState = recentlyClosedComponent.emptyState;
+ let descriptionEls = recentlyClosedEmptyState.descriptionEls;
+ is(
+ descriptionEls[1].querySelector("a").getAttribute("aria-details"),
+ "card-container",
+ "The link within the recently closed empty state has the expected 'aria-details' attribute."
+ );
+
+ let syncedTabsComponent = document.querySelector(
+ "view-syncedtabs[slot=syncedtabs]"
+ );
+ let syncedTabsEmptyState = syncedTabsComponent.emptyState;
+ is(
+ syncedTabsEmptyState.querySelector("button").getAttribute("aria-details"),
+ "empty-container",
+ "The button within the synced tabs empty state has the expected 'aria-details' attribute."
+ );
+
+ // Test keyboard navigation from card-container summary
+ // elements to links/buttons in empty states
+ const tab = async shiftKey => {
+ info(`Tab${shiftKey ? " + Shift" : ""}`);
+ EventUtils.synthesizeKey("KEY_Tab", { shiftKey });
+ };
+ recentlyClosedComponent.cardEl.summaryEl.focus();
+ ok(
+ recentlyClosedComponent.cardEl.summaryEl.matches(":focus"),
+ "Focus should be on the summary element within the recently closed card-container"
+ );
+ // Purge session history to ensure recently closed empty state is shown
+ Services.obs.notifyObservers(null, "browser:purge-session-history");
+ await TestUtils.waitForCondition(
+ () => recentlyClosedComponent.fullyUpdated
+ );
+ await tab();
+ ok(
+ descriptionEls[1].querySelector("a").matches(":focus"),
+ "Focus should be on the link within the recently closed empty state"
+ );
+ await tab();
+ const shadowRoot =
+ SpecialPowers.wrap(syncedTabsComponent).openOrClosedShadowRoot;
+ ok(
+ shadowRoot.querySelector("card-container").summaryEl.matches(":focus"),
+ "Focus should be on summary element of the synced tabs card-container"
+ );
+ await tab();
+ ok(
+ syncedTabsEmptyState.querySelector("button").matches(":focus"),
+ "Focus should be on button element of the synced tabs empty state"
+ );
+ });
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview_general_telemetry.js b/browser/components/firefoxview/tests/browser/browser_firefoxview_general_telemetry.js
new file mode 100644
index 0000000000..51d5caa032
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_general_telemetry.js
@@ -0,0 +1,368 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const CARD_COLLAPSED_EVENT = [
+ ["firefoxview_next", "card_collapsed", "card_container", undefined],
+];
+const CARD_EXPANDED_EVENT = [
+ ["firefoxview_next", "card_expanded", "card_container", undefined],
+];
+let tabSelectedTelemetry = [
+ "firefoxview_next",
+ "tab_selected",
+ "toolbarbutton",
+ undefined,
+ {},
+];
+let enteredTelemetry = [
+ "firefoxview_next",
+ "entered",
+ "firefoxview",
+ undefined,
+ { page: "recentbrowsing" },
+];
+
+add_setup(async () => {
+ registerCleanupFunction(async () => {
+ await SpecialPowers.popPrefEnv();
+ clearHistory();
+ });
+});
+
+add_task(async function firefox_view_entered_telemetry() {
+ await clearAllParentTelemetryEvents();
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ let enteredAndTabSelectedEvents = [tabSelectedTelemetry, enteredTelemetry];
+ await telemetryEvent(enteredAndTabSelectedEvents);
+
+ enteredTelemetry[4] = { page: "recentlyclosed" };
+ enteredAndTabSelectedEvents = [tabSelectedTelemetry, enteredTelemetry];
+
+ navigateToCategory(document, "recentlyclosed");
+ await clearAllParentTelemetryEvents();
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:robots");
+ is(
+ gBrowser.selectedBrowser.currentURI.spec,
+ "about:robots",
+ "The selected tab is about:robots"
+ );
+ await switchToFxViewTab(browser.ownerGlobal);
+ await telemetryEvent(enteredAndTabSelectedEvents);
+ await SpecialPowers.popPrefEnv();
+ // clean up extra tabs
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.tabs.at(-1));
+ }
+ });
+});
+
+add_task(async function test_collapse_and_expand_card() {
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+
+ // Test using Recently Closed card on Recent Browsing page
+ let recentlyClosedComponent = document.querySelector(
+ "view-recentlyclosed[slot=recentlyclosed]"
+ );
+ await TestUtils.waitForCondition(
+ () => recentlyClosedComponent.fullyUpdated
+ );
+ let cardContainer = recentlyClosedComponent.cardEl;
+ is(
+ cardContainer.isExpanded,
+ true,
+ "The card-container is expanded initially"
+ );
+ await clearAllParentTelemetryEvents();
+ // Click the summary to collapse the details disclosure
+ EventUtils.synthesizeMouseAtCenter(cardContainer.summaryEl, {}, content);
+ is(
+ cardContainer.detailsEl.hasAttribute("open"),
+ false,
+ "The card-container is collapsed"
+ );
+ await telemetryEvent(CARD_COLLAPSED_EVENT);
+ // Click the summary again to expand the details disclosure
+ EventUtils.synthesizeMouseAtCenter(cardContainer.summaryEl, {}, content);
+ is(
+ cardContainer.detailsEl.hasAttribute("open"),
+ true,
+ "The card-container is expanded"
+ );
+ await telemetryEvent(CARD_EXPANDED_EVENT);
+ });
+});
+
+add_task(async function test_change_page_telemetry() {
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ let changePageEvent = [
+ [
+ "firefoxview_next",
+ "change_page",
+ "navigation",
+ undefined,
+ { page: "recentlyclosed", source: "category-navigation" },
+ ],
+ ];
+ await clearAllParentTelemetryEvents();
+ navigateToCategory(document, "recentlyclosed");
+ await telemetryEvent(changePageEvent);
+ navigateToCategory(document, "recentbrowsing");
+
+ let openTabsComponent = document.querySelector(
+ "view-opentabs[slot=opentabs]"
+ );
+ let cardContainer =
+ openTabsComponent.shadowRoot.querySelector("view-opentabs-card").cardEl;
+ let viewAllLink = cardContainer.viewAllLink;
+ changePageEvent = [
+ [
+ "firefoxview_next",
+ "change_page",
+ "navigation",
+ undefined,
+ { page: "opentabs", source: "view-all" },
+ ],
+ ];
+ await clearAllParentTelemetryEvents();
+ EventUtils.synthesizeMouseAtCenter(viewAllLink, {}, content);
+ await telemetryEvent(changePageEvent);
+ });
+});
+
+add_task(async function test_browser_context_menu_telemetry() {
+ const menu = document.getElementById("contentAreaContextMenu");
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ await clearAllParentTelemetryEvents();
+
+ // Test browser context menu options
+ const openTabsComponent = document.querySelector("view-opentabs");
+ await TestUtils.waitForCondition(
+ () =>
+ openTabsComponent.shadowRoot.querySelector("view-opentabs-card").tabList
+ .rowEls.length
+ );
+ const [openTabsRow] =
+ openTabsComponent.shadowRoot.querySelector("view-opentabs-card").tabList
+ .rowEls;
+ const promisePopup = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ openTabsRow,
+ { type: "contextmenu" },
+ content
+ );
+ await promisePopup;
+ const promiseNewWindow = BrowserTestUtils.waitForNewWindow();
+ menu.activateItem(menu.querySelector("#context-openlink"));
+ await telemetryEvent([
+ [
+ "firefoxview_next",
+ "browser_context_menu",
+ "tabs",
+ null,
+ { menu_action: "context-openlink", page: "recentbrowsing" },
+ ],
+ ]);
+
+ // Clean up extra window
+ const win = await promiseNewWindow;
+ await BrowserTestUtils.closeWindow(win);
+ });
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_context_menu_new_window_telemetry() {
+ await PlacesUtils.history.insert({
+ url: URLs[0],
+ title: "Example Domain 1",
+ visits: [{ date: new Date() }],
+ });
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ is(
+ document.location.href,
+ "about:firefoxview",
+ "The Recent browsing page is showing."
+ );
+
+ // Test history context menu options
+ await navigateToCategoryAndWait(document, "history");
+ let historyComponent = document.querySelector("view-history");
+ await TestUtils.waitForCondition(() => historyComponent.fullyUpdated);
+ await TestUtils.waitForCondition(
+ () => historyComponent.lists[0].rowEls.length
+ );
+ let firstTabList = historyComponent.lists[0];
+ let firstItem = firstTabList.rowEls[0];
+ let panelList = historyComponent.panelList;
+ EventUtils.synthesizeMouseAtCenter(firstItem.buttonEl, {}, content);
+ await BrowserTestUtils.waitForEvent(panelList, "shown");
+ await clearAllParentTelemetryEvents();
+ let panelItems = Array.from(panelList.children).filter(
+ panelItem => panelItem.nodeName === "PANEL-ITEM"
+ );
+ let openInNewWindowOption = panelItems[1];
+ let contextMenuEvent = [
+ [
+ "firefoxview_next",
+ "context_menu",
+ "tabs",
+ undefined,
+ { menu_action: "open-in-new-window", data_type: "history" },
+ ],
+ ];
+ let newWindowPromise = BrowserTestUtils.waitForNewWindow({
+ url: URLs[0],
+ });
+ EventUtils.synthesizeMouseAtCenter(openInNewWindowOption, {}, content);
+ let win = await newWindowPromise;
+ await telemetryEvent(contextMenuEvent);
+ await BrowserTestUtils.closeWindow(win);
+ info("New window closed.");
+
+ // clean up extra tabs
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.tabs.at(-1));
+ }
+ });
+});
+
+add_task(async function test_context_menu_private_window_telemetry() {
+ await PlacesUtils.history.insert({
+ url: URLs[0],
+ title: "Example Domain 1",
+ visits: [{ date: new Date() }],
+ });
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ is(
+ document.location.href,
+ "about:firefoxview",
+ "The Recent browsing page is showing."
+ );
+
+ // Test history context menu options
+ await navigateToCategoryAndWait(document, "history");
+ let historyComponent = document.querySelector("view-history");
+ await TestUtils.waitForCondition(() => historyComponent.fullyUpdated);
+ await TestUtils.waitForCondition(
+ () => historyComponent.lists[0].rowEls.length
+ );
+ let firstTabList = historyComponent.lists[0];
+ let firstItem = firstTabList.rowEls[0];
+ let panelList = historyComponent.panelList;
+ EventUtils.synthesizeMouseAtCenter(firstItem.buttonEl, {}, content);
+ await BrowserTestUtils.waitForEvent(panelList, "shown");
+ await clearAllParentTelemetryEvents();
+ let panelItems = Array.from(panelList.children).filter(
+ panelItem => panelItem.nodeName === "PANEL-ITEM"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(firstItem.buttonEl, {}, content);
+ info("Context menu button clicked.");
+ await BrowserTestUtils.waitForEvent(panelList, "shown");
+ info("Context menu shown.");
+ await clearAllParentTelemetryEvents();
+ let openInPrivateWindowOption = panelItems[2];
+ let contextMenuEvent = [
+ [
+ "firefoxview_next",
+ "context_menu",
+ "tabs",
+ undefined,
+ { menu_action: "open-in-private-window", data_type: "history" },
+ ],
+ ];
+ let newWindowPromise = BrowserTestUtils.waitForNewWindow({
+ url: URLs[0],
+ });
+ EventUtils.synthesizeMouseAtCenter(openInPrivateWindowOption, {}, content);
+ info("Open in private window context menu option clicked.");
+ let win = await newWindowPromise;
+ info("New private window opened.");
+ await telemetryEvent(contextMenuEvent);
+ ok(
+ PrivateBrowsingUtils.isWindowPrivate(win),
+ "Should have opened a private window."
+ );
+ await BrowserTestUtils.closeWindow(win);
+ info("New private window closed.");
+
+ // clean up extra tabs
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.tabs.at(-1));
+ }
+ });
+});
+
+add_task(async function test_context_menu_delete_from_history_telemetry() {
+ await PlacesUtils.history.clear();
+ await PlacesUtils.history.insert({
+ url: URLs[0],
+ title: "Example Domain 1",
+ visits: [{ date: new Date() }],
+ });
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ is(
+ document.location.href,
+ "about:firefoxview",
+ "The Recent browsing page is showing."
+ );
+
+ // Test history context menu options
+ await navigateToCategoryAndWait(document, "history");
+ let historyComponent = document.querySelector("view-history");
+ await TestUtils.waitForCondition(() => historyComponent.fullyUpdated);
+ await TestUtils.waitForCondition(
+ () => historyComponent.lists[0].rowEls.length
+ );
+ let firstTabList = historyComponent.lists[0];
+ let firstItem = firstTabList.rowEls[0];
+ let panelList = historyComponent.panelList;
+ EventUtils.synthesizeMouseAtCenter(firstItem.buttonEl, {}, content);
+ await BrowserTestUtils.waitForEvent(panelList, "shown");
+ await clearAllParentTelemetryEvents();
+ let panelItems = Array.from(panelList.children).filter(
+ panelItem => panelItem.nodeName === "PANEL-ITEM"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(firstItem.buttonEl, {}, content);
+ info("Context menu button clicked.");
+ await BrowserTestUtils.waitForEvent(panelList, "shown");
+ info("Context menu shown.");
+ await clearAllParentTelemetryEvents();
+ let deleteFromHistoryOption = panelItems[0];
+ ok(
+ deleteFromHistoryOption.textContent.includes("Delete"),
+ "Delete from history button is present in the context menu."
+ );
+ let contextMenuEvent = [
+ [
+ "firefoxview_next",
+ "context_menu",
+ "tabs",
+ undefined,
+ { menu_action: "delete-from-history", data_type: "history" },
+ ],
+ ];
+ EventUtils.synthesizeMouseAtCenter(deleteFromHistoryOption, {}, content);
+ info("Delete from history context menu option clicked.");
+
+ await TestUtils.waitForCondition(
+ () =>
+ !historyComponent.paused &&
+ historyComponent.fullyUpdated &&
+ !historyComponent.lists.length
+ );
+ await telemetryEvent(contextMenuEvent);
+
+ // clean up extra tabs
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.tabs.at(-1));
+ }
+ });
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview_navigation.js b/browser/components/firefoxview/tests/browser/browser_firefoxview_navigation.js
new file mode 100644
index 0000000000..80206dd945
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_navigation.js
@@ -0,0 +1,96 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const URL_BASE = `${getFirefoxViewURL()}#`;
+
+function assertCorrectPage(document, name, event) {
+ is(
+ document.location.hash,
+ `#${name}`,
+ `Navigation button for ${name} navigates to ${URL_BASE + name} on ${event}.`
+ );
+ is(
+ document.querySelector("named-deck").selectedViewName,
+ name,
+ "The correct deck child is selected"
+ );
+}
+
+add_task(async function test_side_component_navigation_by_click() {
+ await withFirefoxView({}, async browser => {
+ await SimpleTest.promiseFocus(browser);
+
+ const { document } = browser.contentWindow;
+ let win = browser.ownerGlobal;
+ const categoryButtons = document.querySelectorAll("fxview-category-button");
+
+ for (let element of categoryButtons) {
+ const name = element.name;
+ let buttonClicked = BrowserTestUtils.waitForEvent(
+ element.buttonEl,
+ "click",
+ win
+ );
+
+ info(`Clicking navigation button for ${name}`);
+ EventUtils.synthesizeMouseAtCenter(element.buttonEl, {}, content);
+ await buttonClicked;
+
+ assertCorrectPage(document, name, "click");
+ }
+ });
+});
+
+add_task(async function test_side_component_navigation_by_keyboard() {
+ await withFirefoxView({}, async browser => {
+ await SimpleTest.promiseFocus(browser);
+
+ const { document } = browser.contentWindow;
+ let win = browser.ownerGlobal;
+ const categoryButtons = document.querySelectorAll("fxview-category-button");
+ const firstButton = categoryButtons[0];
+
+ firstButton.focus();
+ is(
+ document.activeElement,
+ firstButton,
+ "The first category button has focus"
+ );
+
+ for (let element of Array.from(categoryButtons).slice(1)) {
+ const name = element.name;
+ let buttonFocused = BrowserTestUtils.waitForEvent(element, "focus", win);
+
+ info(`Focus is on ${document.activeElement.name}`);
+ info(`Arrow down on navigation to ${name}`);
+ EventUtils.synthesizeKey("KEY_ArrowDown", {}, win);
+ await buttonFocused;
+
+ assertCorrectPage(document, name, "key press");
+ }
+ });
+});
+
+add_task(async function test_direct_navigation_to_correct_category() {
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ const categoryButtons = document.querySelectorAll("fxview-category-button");
+ const namedDeck = document.querySelector("named-deck");
+
+ for (let element of categoryButtons) {
+ const name = element.name;
+
+ info(`Navigating to ${URL_BASE + name}`);
+ document.location.assign(URL_BASE + name);
+ await BrowserTestUtils.waitForCondition(() => {
+ return namedDeck.selectedViewName === name;
+ }, "Wait for navigation to complete");
+
+ is(
+ namedDeck.selectedViewName,
+ name,
+ `The correct deck child for category ${name} is selected`
+ );
+ }
+ });
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview_paused.js b/browser/components/firefoxview/tests/browser/browser_firefoxview_paused.js
new file mode 100644
index 0000000000..c95ac4fcf5
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_paused.js
@@ -0,0 +1,407 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const tabURL1 = "data:,Tab1";
+const tabURL2 = "data:,Tab2";
+const tabURL3 = "data:,Tab3";
+
+const { NonPrivateTabs } = ChromeUtils.importESModule(
+ "resource:///modules/OpenTabs.sys.mjs"
+);
+const TestTabs = {};
+
+function getTopLevelViewElements(document) {
+ return {
+ recentBrowsingView: document.querySelector(
+ "named-deck > view-recentbrowsing"
+ ),
+ recentlyClosedView: document.querySelector(
+ "named-deck > view-recentlyclosed"
+ ),
+ openTabsView: document.querySelector("named-deck > view-opentabs"),
+ };
+}
+
+async function getElements(document) {
+ let { recentBrowsingView, recentlyClosedView, openTabsView } =
+ getTopLevelViewElements(document);
+ let recentBrowsingOpenTabsView =
+ recentBrowsingView.querySelector("view-opentabs");
+ let recentBrowsingOpenTabsList =
+ recentBrowsingOpenTabsView?.viewCards[0]?.tabList;
+ let recentBrowsingRecentlyClosedTabsView = recentBrowsingView.querySelector(
+ "view-recentlyclosed"
+ );
+ await TestUtils.waitForCondition(
+ () => recentBrowsingRecentlyClosedTabsView.fullyUpdated
+ );
+ let recentBrowsingRecentlyClosedTabsList =
+ recentBrowsingRecentlyClosedTabsView?.tabList;
+ if (recentlyClosedView.firstUpdateComplete) {
+ await TestUtils.waitForCondition(() => recentlyClosedView.fullyUpdated);
+ }
+ let recentlyClosedList = recentlyClosedView.tabList;
+ await openTabsView.openTabsTarget.readyWindowsPromise;
+ await openTabsView.updateComplete;
+ let openTabsList =
+ openTabsView.shadowRoot.querySelector("view-opentabs-card")?.tabList;
+
+ return {
+ // recentbrowsing
+ recentBrowsingView,
+ recentBrowsingOpenTabsView,
+ recentBrowsingOpenTabsList,
+ recentBrowsingRecentlyClosedTabsView,
+ recentBrowsingRecentlyClosedTabsList,
+
+ // recentlyclosed
+ recentlyClosedView,
+ recentlyClosedList,
+
+ // opentabs
+ openTabsView,
+ openTabsList,
+ };
+}
+
+async function nextFrame(global = window) {
+ await new Promise(resolve => {
+ global.requestAnimationFrame(() => {
+ global.requestAnimationFrame(resolve);
+ });
+ });
+}
+
+async function setupOpenAndClosedTabs() {
+ TestTabs.tab1 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ tabURL1
+ );
+ TestTabs.tab2 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ tabURL2
+ );
+ TestTabs.tab3 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ tabURL3
+ );
+ // close a tab so we have recently-closed tabs content
+ await SessionStoreTestUtils.closeTab(TestTabs.tab3);
+}
+
+function assertSpiesCalled(spiesMap, expectCalled) {
+ let message = expectCalled ? "to be called" : "to not be called";
+ for (let [elem, renderSpy] of spiesMap.entries()) {
+ is(
+ expectCalled,
+ renderSpy.called,
+ `Expected the render method spy on element ${elem.localName} ${message}`
+ );
+ }
+}
+
+async function checkFxRenderCalls(browser, elements, selectedView) {
+ const sandbox = sinon.createSandbox();
+ const topLevelViews = getTopLevelViewElements(browser.contentDocument);
+
+ // sanity-check the selectedView we were given
+ ok(
+ Object.values(topLevelViews).find(view => view == selectedView),
+ `The selected view is in the topLevelViews`
+ );
+
+ const elementSpies = new Map();
+ const viewSpies = new Map();
+
+ for (let [elemName, elem] of Object.entries(topLevelViews)) {
+ let spy;
+ if (elem.render.isSinonProxy) {
+ spy = elem.render;
+ } else {
+ info(`Creating spy for render on element: ${elemName}`);
+ spy = sandbox.spy(elem, "render");
+ }
+ viewSpies.set(elem, spy);
+ }
+ for (let [elemName, elem] of Object.entries(elements)) {
+ let spy;
+ if (elem.render.isSinonProxy) {
+ spy = elem.render;
+ } else {
+ info(`Creating spy for render on element: ${elemName}`);
+ spy = sandbox.spy(elem, "render");
+ }
+ elementSpies.set(elem, spy);
+ }
+
+ info("test switches to tab2");
+ let tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabRecencyChange"
+ );
+ await BrowserTestUtils.switchTab(gBrowser, TestTabs.tab2);
+ await tabChangeRaised;
+ info(
+ "TabRecencyChange event was raised, check no render() methods were called"
+ );
+ assertSpiesCalled(viewSpies, false);
+ assertSpiesCalled(elementSpies, false);
+ for (let renderSpy of [...viewSpies.values(), ...elementSpies.values()]) {
+ renderSpy.resetHistory();
+ }
+
+ // check all the top-level views are paused
+ ok(
+ topLevelViews.recentBrowsingView.paused,
+ "The recent-browsing view is paused"
+ );
+ ok(
+ topLevelViews.recentlyClosedView.paused,
+ "The recently-closed tabs view is paused"
+ );
+ ok(topLevelViews.openTabsView.paused, "The open tabs view is paused");
+
+ await nextFrame();
+ info("test removes tab1");
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabRecencyChange"
+ );
+ await BrowserTestUtils.removeTab(TestTabs.tab1);
+ await tabChangeRaised;
+
+ assertSpiesCalled(viewSpies, false);
+ assertSpiesCalled(elementSpies, false);
+
+ for (let renderSpy of [...viewSpies.values(), ...elementSpies.values()]) {
+ renderSpy.resetHistory();
+ }
+
+ info("test will re-open fxview");
+ await openFirefoxViewTab(window);
+ await nextFrame();
+
+ assertSpiesCalled(elementSpies, true);
+ ok(
+ selectedView.render.called,
+ `Render was called on the selected top-level view: ${selectedView.localName}`
+ );
+
+ // check all the other views did not render
+ viewSpies.delete(selectedView);
+ assertSpiesCalled(viewSpies, false);
+
+ sandbox.restore();
+}
+
+add_task(async function test_recentbrowsing() {
+ await setupOpenAndClosedTabs();
+
+ await withFirefoxView({}, async browser => {
+ const document = browser.contentDocument;
+ is(document.querySelector("named-deck").selectedViewName, "recentbrowsing");
+
+ const {
+ recentBrowsingView,
+ recentBrowsingOpenTabsView,
+ recentBrowsingOpenTabsList,
+ recentBrowsingRecentlyClosedTabsView,
+ recentBrowsingRecentlyClosedTabsList,
+ } = await getElements(document);
+
+ ok(recentBrowsingView, "Found the recent-browsing view");
+ ok(recentBrowsingOpenTabsView, "Found the recent-browsing open tabs view");
+ ok(recentBrowsingOpenTabsList, "Found the recent-browsing open tabs list");
+ ok(
+ recentBrowsingRecentlyClosedTabsView,
+ "Found the recent-browsing recently-closed tabs view"
+ );
+ ok(
+ recentBrowsingRecentlyClosedTabsList,
+ "Found the recent-browsing recently-closed tabs list"
+ );
+
+ // Collapse the Open Tabs card
+ let cardContainer = recentBrowsingOpenTabsView.viewCards[0]?.cardEl;
+ await EventUtils.synthesizeMouseAtCenter(
+ cardContainer.summaryEl,
+ {},
+ content
+ );
+ await TestUtils.waitForCondition(
+ () => !cardContainer.detailsEl.hasAttribute("open")
+ );
+
+ ok(
+ recentBrowsingOpenTabsList.updatesPaused,
+ "The Open Tabs list is paused after its card is collapsed."
+ );
+ ok(
+ !recentBrowsingOpenTabsList.intervalID,
+ "The intervalID for the Open Tabs list is undefined while updates are paused."
+ );
+
+ // Expand the Open Tabs card
+ await EventUtils.synthesizeMouseAtCenter(
+ cardContainer.summaryEl,
+ {},
+ content
+ );
+ await TestUtils.waitForCondition(() =>
+ cardContainer.detailsEl.hasAttribute("open")
+ );
+
+ ok(
+ !recentBrowsingOpenTabsList.updatesPaused,
+ "The Open Tabs list is unpaused after its card is expanded."
+ );
+ ok(
+ recentBrowsingOpenTabsList.intervalID,
+ "The intervalID for the Open Tabs list is defined while updates are unpaused."
+ );
+
+ // Collapse the Recently Closed card
+ let recentlyClosedCardContainer =
+ recentBrowsingRecentlyClosedTabsView.cardEl;
+ await EventUtils.synthesizeMouseAtCenter(
+ recentlyClosedCardContainer.summaryEl,
+ {},
+ content
+ );
+ await TestUtils.waitForCondition(
+ () => !recentlyClosedCardContainer.detailsEl.hasAttribute("open")
+ );
+
+ ok(
+ recentBrowsingRecentlyClosedTabsList.updatesPaused,
+ "The Recently Closed list is paused after its card is collapsed."
+ );
+ ok(
+ !recentBrowsingRecentlyClosedTabsList.intervalID,
+ "The intervalID for the Open Tabs list is undefined while updates are paused."
+ );
+
+ // Expand the Recently Closed card
+ await EventUtils.synthesizeMouseAtCenter(
+ recentlyClosedCardContainer.summaryEl,
+ {},
+ content
+ );
+ await TestUtils.waitForCondition(() =>
+ recentlyClosedCardContainer.detailsEl.hasAttribute("open")
+ );
+
+ ok(
+ !recentBrowsingRecentlyClosedTabsList.updatesPaused,
+ "The Recently Closed list is unpaused after its card is expanded."
+ );
+ ok(
+ recentBrowsingRecentlyClosedTabsList.intervalID,
+ "The intervalID for the Recently Closed list is defined while updates are unpaused."
+ );
+
+ await checkFxRenderCalls(
+ browser,
+ {
+ recentBrowsingView,
+ recentBrowsingOpenTabsView,
+ recentBrowsingOpenTabsList,
+ recentBrowsingRecentlyClosedTabsView,
+ recentBrowsingRecentlyClosedTabsList,
+ },
+ recentBrowsingView
+ );
+ });
+ await BrowserTestUtils.removeTab(TestTabs.tab2);
+});
+
+add_task(async function test_opentabs() {
+ await setupOpenAndClosedTabs();
+
+ await withFirefoxView({}, async browser => {
+ const document = browser.contentDocument;
+ const { openTabsView } = getTopLevelViewElements(document);
+
+ await navigateToCategoryAndWait(document, "opentabs");
+
+ const { openTabsList } = await getElements(document);
+ ok(openTabsView, "Found the open tabs view");
+ ok(openTabsList, "Found the first open tabs list");
+ ok(!openTabsView.paused, "The open tabs view is un-paused");
+ is(openTabsView.slot, "selected", "The open tabs view is selected");
+
+ // Collapse the Open Tabs card
+ let cardContainer = openTabsView.viewCards[0]?.cardEl;
+ await EventUtils.synthesizeMouseAtCenter(
+ cardContainer.summaryEl,
+ {},
+ content
+ );
+ await TestUtils.waitForCondition(
+ () => !cardContainer.detailsEl.hasAttribute("open")
+ );
+
+ ok(
+ openTabsList.updatesPaused,
+ "The Open Tabs list is paused after its card is collapsed."
+ );
+ ok(
+ !openTabsList.intervalID,
+ "The intervalID for the Open Tabs list is undefined while updates are paused."
+ );
+
+ // Expand the Open Tabs card
+ await EventUtils.synthesizeMouseAtCenter(
+ cardContainer.summaryEl,
+ {},
+ content
+ );
+ await TestUtils.waitForCondition(() =>
+ cardContainer.detailsEl.hasAttribute("open")
+ );
+
+ ok(
+ !openTabsList.updatesPaused,
+ "The Open Tabs list is unpaused after its card is expanded."
+ );
+ ok(
+ openTabsList.intervalID,
+ "The intervalID for the Open Tabs list is defined while updates are unpaused."
+ );
+
+ await checkFxRenderCalls(
+ browser,
+ {
+ openTabsView,
+ openTabsList,
+ },
+ openTabsView
+ );
+ });
+ await BrowserTestUtils.removeTab(TestTabs.tab2);
+});
+
+add_task(async function test_recentlyclosed() {
+ await setupOpenAndClosedTabs();
+
+ await withFirefoxView({}, async browser => {
+ const document = browser.contentDocument;
+ const { recentlyClosedView } = getTopLevelViewElements(document);
+ await navigateToCategoryAndWait(document, "recentlyclosed");
+
+ const { recentlyClosedList } = await getElements(document);
+ ok(recentlyClosedView, "Found the recently-closed view");
+ ok(recentlyClosedList, "Found the recently-closed list");
+ ok(!recentlyClosedView.paused, "The recently-closed view is un-paused");
+
+ await checkFxRenderCalls(
+ browser,
+ {
+ recentlyClosedView,
+ recentlyClosedList,
+ },
+ recentlyClosedView
+ );
+ });
+ await BrowserTestUtils.removeTab(TestTabs.tab2);
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview_search_telemetry.js b/browser/components/firefoxview/tests/browser/browser_firefoxview_search_telemetry.js
new file mode 100644
index 0000000000..2ea2429c15
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_search_telemetry.js
@@ -0,0 +1,629 @@
+let gInitialTab;
+let gInitialTabURL;
+
+const NUMBER_OF_TABS = 6;
+
+const syncedTabsData = [
+ {
+ id: 1,
+ name: "This Device",
+ isCurrentDevice: true,
+ type: "desktop",
+ tabs: Array(NUMBER_OF_TABS)
+ .fill({
+ type: "tab",
+ title: "Internet for people, not profits - Mozilla",
+ icon: "https://www.mozilla.org/media/img/favicons/mozilla/favicon.d25d81d39065.ico",
+ client: 1,
+ })
+ .map((tab, i) => ({ ...tab, url: URLs[i] })),
+ },
+];
+
+const searchEvent = page => {
+ return [
+ ["firefoxview_next", "search_initiated", "search", undefined, { page }],
+ ];
+};
+
+const cleanUp = () => {
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.tabs.at(-1));
+ }
+};
+
+add_setup(async () => {
+ gInitialTab = gBrowser.selectedTab;
+ gInitialTabURL = gBrowser.selectedBrowser.currentURI.spec;
+ registerCleanupFunction(async () => {
+ clearHistory();
+ });
+});
+
+add_task(async function test_search_initiated_telemetry() {
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ await clearAllParentTelemetryEvents();
+
+ is(document.location.hash, "", "Searching within recent browsing.");
+ const recentBrowsing = document.querySelector("view-recentbrowsing");
+ info("Input a search query");
+ EventUtils.synthesizeMouseAtCenter(
+ recentBrowsing.searchTextbox,
+ {},
+ content
+ );
+ EventUtils.sendString("example.com", content);
+ await telemetryEvent(searchEvent("recentbrowsing"));
+
+ await navigateToCategoryAndWait(document, "opentabs");
+ await clearAllParentTelemetryEvents();
+ is(document.location.hash, "#opentabs", "Searching within open tabs.");
+ const openTabs = document.querySelector("named-deck > view-opentabs");
+ info("Input a search query");
+ EventUtils.synthesizeMouseAtCenter(openTabs.searchTextbox, {}, content);
+ EventUtils.sendString("example.com", content);
+ await telemetryEvent(searchEvent("opentabs"));
+
+ await navigateToCategoryAndWait(document, "recentlyclosed");
+ await clearAllParentTelemetryEvents();
+ is(
+ document.location.hash,
+ "#recentlyclosed",
+ "Searching within recently closed."
+ );
+ const recentlyClosed = document.querySelector(
+ "named-deck > view-recentlyclosed"
+ );
+ info("Input a search query");
+ EventUtils.synthesizeMouseAtCenter(
+ recentlyClosed.searchTextbox,
+ {},
+ content
+ );
+ EventUtils.sendString("example.com", content);
+ await telemetryEvent(searchEvent("recentlyclosed"));
+
+ await navigateToCategoryAndWait(document, "syncedtabs");
+ await clearAllParentTelemetryEvents();
+ is(document.location.hash, "#syncedtabs", "Searching within synced tabs.");
+ const syncedTabs = document.querySelector("named-deck > view-syncedtabs");
+ info("Input a search query");
+ EventUtils.synthesizeMouseAtCenter(syncedTabs.searchTextbox, {}, content);
+ EventUtils.sendString("example.com", content);
+ await telemetryEvent(searchEvent("syncedtabs"));
+
+ await navigateToCategoryAndWait(document, "history");
+ await clearAllParentTelemetryEvents();
+ is(document.location.hash, "#history", "Searching within history.");
+ const history = document.querySelector("named-deck > view-history");
+ info("Input a search query");
+ EventUtils.synthesizeMouseAtCenter(history.searchTextbox, {}, content);
+ EventUtils.sendString("example.com", content);
+ await telemetryEvent(searchEvent("history"));
+
+ await clearAllParentTelemetryEvents();
+ });
+});
+
+add_task(async function test_show_all_recentlyclosed_telemetry() {
+ for (let i = 0; i < NUMBER_OF_TABS; i++) {
+ await open_then_close(URLs[1]);
+ }
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ const recentBrowsing = document.querySelector("view-recentbrowsing");
+
+ info("Input a search query.");
+ EventUtils.synthesizeMouseAtCenter(
+ recentBrowsing.searchTextbox,
+ {},
+ content
+ );
+ EventUtils.sendString("example.com", content);
+ const recentlyclosedSlot = recentBrowsing.querySelector(
+ "[slot='recentlyclosed']"
+ );
+ await TestUtils.waitForCondition(
+ () =>
+ recentlyclosedSlot.tabList.rowEls.length === 5 &&
+ recentlyclosedSlot.shadowRoot.querySelector(
+ "[data-l10n-id='firefoxview-show-all']"
+ ),
+ "Expected search results are not shown yet."
+ );
+ await clearAllParentTelemetryEvents();
+
+ info("Click the Show All link.");
+ const showAllButton = recentlyclosedSlot.shadowRoot.querySelector(
+ "[data-l10n-id='firefoxview-show-all']"
+ );
+ await TestUtils.waitForCondition(() => !showAllButton.hidden);
+ ok(!showAllButton.hidden, "Show all button is visible");
+ await TestUtils.waitForCondition(() => {
+ EventUtils.synthesizeMouseAtCenter(showAllButton, {}, content);
+ if (recentlyclosedSlot.tabList.rowEls.length === NUMBER_OF_TABS) {
+ return true;
+ }
+ return false;
+ }, "All search results are not shown.");
+
+ await telemetryEvent([
+ [
+ "firefoxview_next",
+ "search_show_all",
+ "showallbutton",
+ null,
+ { section: "recentlyclosed" },
+ ],
+ ]);
+ });
+});
+
+add_task(async function test_show_all_opentabs_telemetry() {
+ for (let i = 0; i < NUMBER_OF_TABS; i++) {
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, URLs[1]);
+ }
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ const recentBrowsing = document.querySelector("view-recentbrowsing");
+
+ info("Input a search query.");
+ EventUtils.synthesizeMouseAtCenter(
+ recentBrowsing.searchTextbox,
+ {},
+ content
+ );
+ EventUtils.sendString(URLs[1], content);
+ const opentabsSlot = recentBrowsing.querySelector("[slot='opentabs']");
+ await TestUtils.waitForCondition(
+ () => opentabsSlot.viewCards[0].tabList.rowEls.length === 5,
+ "Expected search results are not shown yet."
+ );
+ await clearAllParentTelemetryEvents();
+
+ info("Click the Show All link.");
+ const showAllButton = opentabsSlot.viewCards[0].shadowRoot.querySelector(
+ "[data-l10n-id='firefoxview-show-all']"
+ );
+ await TestUtils.waitForCondition(() => !showAllButton.hidden);
+ ok(!showAllButton.hidden, "Show all button is visible");
+ await TestUtils.waitForCondition(() => {
+ EventUtils.synthesizeMouseAtCenter(showAllButton, {}, content);
+ if (opentabsSlot.viewCards[0].tabList.rowEls.length === NUMBER_OF_TABS) {
+ return true;
+ }
+ return false;
+ }, "All search results are not shown.");
+
+ await telemetryEvent([
+ [
+ "firefoxview_next",
+ "search_initiated",
+ "search",
+ null,
+ { page: "recentbrowsing" },
+ ],
+ [
+ "firefoxview_next",
+ "search_show_all",
+ "showallbutton",
+ null,
+ { section: "opentabs" },
+ ],
+ ]);
+ });
+
+ await SimpleTest.promiseFocus(window);
+ await promiseAllButPrimaryWindowClosed();
+ await BrowserTestUtils.switchTab(gBrowser, gInitialTab);
+ await closeFirefoxViewTab(window);
+
+ cleanUp();
+});
+
+add_task(async function test_show_all_syncedtabs_telemetry() {
+ TabsSetupFlowManager.resetInternalState();
+
+ const sandbox = setupRecentDeviceListMocks();
+ const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs");
+ let mockTabs1 = getMockTabData(syncedTabsData);
+ let getRecentTabsResult = mockTabs1;
+ syncedTabsMock.callsFake(() => {
+ info(
+ `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n`
+ );
+ return Promise.resolve(getRecentTabsResult);
+ });
+ sandbox.stub(SyncedTabs, "getTabClients").callsFake(() => {
+ return Promise.resolve(syncedTabsData);
+ });
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+ const recentBrowsing = document.querySelector("view-recentbrowsing");
+
+ info("Input a search query.");
+ EventUtils.synthesizeMouseAtCenter(
+ recentBrowsing.searchTextbox,
+ {},
+ content
+ );
+ EventUtils.sendString("mozilla", content);
+ const syncedtabsSlot = recentBrowsing.querySelector("[slot='syncedtabs']");
+ await TestUtils.waitForCondition(
+ () =>
+ syncedtabsSlot.fullyUpdated &&
+ syncedtabsSlot.tabLists.length === 1 &&
+ Promise.all(
+ Array.from(syncedtabsSlot.tabLists).map(
+ tabList => tabList.updateComplete
+ )
+ ),
+ "Synced Tabs component is done updating."
+ );
+ syncedtabsSlot.tabLists[0].scrollIntoView();
+ await TestUtils.waitForCondition(
+ () => syncedtabsSlot.tabLists[0].rowEls.length === 5,
+ "Expected search results are not shown yet."
+ );
+ await clearAllParentTelemetryEvents();
+
+ const showAllButton = await TestUtils.waitForCondition(() =>
+ syncedtabsSlot.shadowRoot.querySelector(
+ "[data-l10n-id='firefoxview-show-all']"
+ )
+ );
+ info("Scroll show all button into view.");
+ showAllButton.scrollIntoView();
+ await TestUtils.waitForCondition(() => !showAllButton.hidden);
+ ok(!showAllButton.hidden, "Show all button is visible");
+ info("Click the Show All link.");
+ await TestUtils.waitForCondition(() => {
+ EventUtils.synthesizeMouseAtCenter(showAllButton, {}, content);
+ if (syncedtabsSlot.tabLists[0].rowEls.length === NUMBER_OF_TABS) {
+ return true;
+ }
+ return false;
+ }, "All search results are not shown.");
+
+ await telemetryEvent([
+ [
+ "firefoxview_next",
+ "search_initiated",
+ "search",
+ null,
+ { page: "recentbrowsing" },
+ ],
+ [
+ "firefoxview_next",
+ "search_show_all",
+ "showallbutton",
+ null,
+ { section: "syncedtabs" },
+ ],
+ ]);
+ });
+
+ await tearDown(sandbox);
+});
+
+add_task(async function test_sort_history_search_telemetry() {
+ for (let i = 0; i < NUMBER_OF_TABS; i++) {
+ await open_then_close(URLs[i]);
+ }
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "history");
+ const historyComponent = document.querySelector("view-history");
+
+ const searchTextbox = await TestUtils.waitForCondition(
+ () => historyComponent.searchTextbox,
+ "The search textbox is displayed."
+ );
+
+ info("Input a search query.");
+ EventUtils.synthesizeMouseAtCenter(searchTextbox, {}, content);
+ EventUtils.sendString("example.com", content);
+ await TestUtils.waitForCondition(() => {
+ const { rowEls } = historyComponent.lists[0];
+ return rowEls.length === 1;
+ }, "There is one matching search result.");
+ await clearAllParentTelemetryEvents();
+ // Select sort by site option
+ await EventUtils.synthesizeMouseAtCenter(
+ historyComponent.sortInputs[1],
+ {},
+ content
+ );
+ await TestUtils.waitForCondition(() => historyComponent.fullyUpdated);
+ await telemetryEvent([
+ [
+ "firefoxview_next",
+ "sort_history",
+ "tabs",
+ null,
+ { sort_type: "site", search_start: "true" },
+ ],
+ ]);
+ await clearAllParentTelemetryEvents();
+
+ // Select sort by date option
+ await EventUtils.synthesizeMouseAtCenter(
+ historyComponent.sortInputs[0],
+ {},
+ content
+ );
+ await TestUtils.waitForCondition(() => historyComponent.fullyUpdated);
+ await telemetryEvent([
+ [
+ "firefoxview_next",
+ "sort_history",
+ "tabs",
+ null,
+ { sort_type: "date", search_start: "true" },
+ ],
+ ]);
+ });
+});
+
+add_task(async function test_cumulative_searches_recent_browsing_telemetry() {
+ const cumulativeSearchesHistogram =
+ TelemetryTestUtils.getAndClearKeyedHistogram(
+ "FIREFOX_VIEW_CUMULATIVE_SEARCHES"
+ );
+ await PlacesUtils.history.clear();
+ await open_then_close(URLs[0]);
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+
+ is(document.location.hash, "", "Searching within recent browsing.");
+ const recentBrowsing = document.querySelector("view-recentbrowsing");
+ info("Input a search query");
+ EventUtils.synthesizeMouseAtCenter(
+ recentBrowsing.searchTextbox,
+ {},
+ content
+ );
+ EventUtils.sendString(URLs[0], content);
+ const recentlyclosedSlot = recentBrowsing.querySelector(
+ "[slot='recentlyclosed']"
+ );
+ await TestUtils.waitForCondition(
+ () =>
+ recentlyclosedSlot?.tabList?.rowEls?.length &&
+ recentlyclosedSlot?.searchQuery,
+ "Expected search results are not shown yet."
+ );
+
+ EventUtils.synthesizeMouseAtCenter(
+ recentlyclosedSlot.tabList.rowEls[0].mainEl,
+ {},
+ content
+ );
+ await TestUtils.waitForCondition(
+ () => "recentbrowsing" in cumulativeSearchesHistogram.snapshot(),
+ `recentbrowsing key not found in cumulativeSearchesHistogram snapshot: ${JSON.stringify(
+ cumulativeSearchesHistogram.snapshot()
+ )}`
+ );
+ TelemetryTestUtils.assertKeyedHistogramValue(
+ cumulativeSearchesHistogram,
+ "recentbrowsing",
+ 1,
+ 1
+ );
+ });
+
+ cleanUp();
+});
+
+add_task(async function test_cumulative_searches_recently_closed_telemetry() {
+ const cumulativeSearchesHistogram =
+ TelemetryTestUtils.getAndClearKeyedHistogram(
+ "FIREFOX_VIEW_CUMULATIVE_SEARCHES"
+ );
+ await PlacesUtils.history.clear();
+ await open_then_close(URLs[0]);
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+
+ await navigateToCategoryAndWait(document, "recentlyclosed");
+ is(
+ document.location.hash,
+ "#recentlyclosed",
+ "Searching within recently closed."
+ );
+ const recentlyClosed = document.querySelector(
+ "named-deck > view-recentlyclosed"
+ );
+ const searchTextbox = await TestUtils.waitForCondition(() => {
+ return recentlyClosed.searchTextbox;
+ });
+ info("Input a search query");
+ EventUtils.synthesizeMouseAtCenter(searchTextbox, {}, content);
+ EventUtils.sendString(URLs[0], content);
+ // eslint-disable-next-line no-unused-vars
+ const [recentlyclosedSlot, tabList] = await waitForRecentlyClosedTabsList(
+ document
+ );
+ await TestUtils.waitForCondition(() => recentlyclosedSlot?.searchQuery);
+
+ await click_recently_closed_tab_item(tabList[0]);
+
+ TelemetryTestUtils.assertKeyedHistogramValue(
+ cumulativeSearchesHistogram,
+ "recentlyclosed",
+ 1,
+ 1
+ );
+ });
+
+ cleanUp();
+});
+
+add_task(async function test_cumulative_searches_open_tabs_telemetry() {
+ const cumulativeSearchesHistogram =
+ TelemetryTestUtils.getAndClearKeyedHistogram(
+ "FIREFOX_VIEW_CUMULATIVE_SEARCHES"
+ );
+ await PlacesUtils.history.clear();
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, URLs[0]);
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+
+ await navigateToCategoryAndWait(document, "opentabs");
+ is(document.location.hash, "#opentabs", "Searching within open tabs.");
+ const openTabs = document.querySelector("named-deck > view-opentabs");
+
+ info("Input a search query");
+ EventUtils.synthesizeMouseAtCenter(openTabs.searchTextbox, {}, content);
+ EventUtils.sendString(URLs[0], content);
+ let cards;
+ await TestUtils.waitForCondition(() => {
+ cards = getOpenTabsCards(openTabs);
+ return cards.length == 1;
+ });
+ await TestUtils.waitForCondition(
+ () => cards[0].tabList.rowEls.length === 1 && openTabs?.searchQuery,
+ "Expected search results are not shown yet."
+ );
+
+ info("Click a search result tab");
+ EventUtils.synthesizeMouseAtCenter(
+ cards[0].tabList.rowEls[0].mainEl,
+ {},
+ content
+ );
+ });
+
+ TelemetryTestUtils.assertKeyedHistogramValue(
+ cumulativeSearchesHistogram,
+ "opentabs",
+ 1,
+ 1
+ );
+
+ cleanUp();
+});
+
+add_task(async function test_cumulative_searches_history_telemetry() {
+ const cumulativeSearchesHistogram =
+ TelemetryTestUtils.getAndClearKeyedHistogram(
+ "FIREFOX_VIEW_CUMULATIVE_SEARCHES"
+ );
+ await PlacesUtils.history.clear();
+ await open_then_close(URLs[0]);
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+
+ await navigateToCategoryAndWait(document, "history");
+ is(document.location.hash, "#history", "Searching within history.");
+ const history = document.querySelector("named-deck > view-history");
+ const searchTextbox = await TestUtils.waitForCondition(() => {
+ return history.searchTextbox;
+ });
+
+ info("Input a search query");
+ EventUtils.synthesizeMouseAtCenter(searchTextbox, {}, content);
+ EventUtils.sendString(URLs[0], content);
+ await TestUtils.waitForCondition(
+ () =>
+ history.fullyUpdated &&
+ history?.lists[0].rowEls?.length === 1 &&
+ history?.searchQuery,
+ "Expected search results are not shown yet."
+ );
+
+ info("Click a search result tab");
+ EventUtils.synthesizeMouseAtCenter(
+ history.lists[0].rowEls[0].mainEl,
+ {},
+ content
+ );
+
+ TelemetryTestUtils.assertKeyedHistogramValue(
+ cumulativeSearchesHistogram,
+ "history",
+ 1,
+ 1
+ );
+ });
+
+ cleanUp();
+});
+
+add_task(async function test_cumulative_searches_syncedtabs_telemetry() {
+ const cumulativeSearchesHistogram =
+ TelemetryTestUtils.getAndClearKeyedHistogram(
+ "FIREFOX_VIEW_CUMULATIVE_SEARCHES"
+ );
+ await PlacesUtils.history.clear();
+ TabsSetupFlowManager.resetInternalState();
+
+ const sandbox = setupRecentDeviceListMocks();
+ const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs");
+ let mockTabs1 = getMockTabData(syncedTabsData);
+ let getRecentTabsResult = mockTabs1;
+ syncedTabsMock.callsFake(() => {
+ info(
+ `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n`
+ );
+ return Promise.resolve(getRecentTabsResult);
+ });
+ sandbox.stub(SyncedTabs, "getTabClients").callsFake(() => {
+ return Promise.resolve(syncedTabsData);
+ });
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ await navigateToCategoryAndWait(document, "syncedtabs");
+ is(document.location.hash, "#syncedtabs", "Searching within synced tabs.");
+ let syncedTabs = document.querySelector(
+ "view-syncedtabs:not([slot=syncedtabs])"
+ );
+
+ info("Input a search query.");
+ EventUtils.synthesizeMouseAtCenter(syncedTabs.searchTextbox, {}, content);
+ EventUtils.sendString(URLs[0], content);
+ await TestUtils.waitForCondition(
+ () =>
+ syncedTabs.fullyUpdated &&
+ syncedTabs.tabLists.length === 1 &&
+ Promise.all(
+ Array.from(syncedTabs.tabLists).map(tabList => tabList.updateComplete)
+ ),
+ "Synced Tabs component is done updating."
+ );
+ await TestUtils.waitForCondition(
+ () =>
+ syncedTabs.tabLists[0].rowEls.length === 1 && syncedTabs?.searchQuery,
+ "Expected search results are not shown yet."
+ );
+
+ info("Click a search result tab");
+ EventUtils.synthesizeMouseAtCenter(
+ syncedTabs.tabLists[0].rowEls[0].mainEl,
+ {},
+ content
+ );
+
+ TelemetryTestUtils.assertKeyedHistogramValue(
+ cumulativeSearchesHistogram,
+ "syncedtabs",
+ 1,
+ 1
+ );
+ });
+
+ cleanUp();
+ await tearDown(sandbox);
+});
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..f1ac7d6742
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_tab.js
@@ -0,0 +1,370 @@
+/* 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);
+
+ Assert.strictEqual(
+ 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);
+ });
+});
+
+add_task(async function testFxViewEntryPointsInPrivateBrowsing() {
+ async function checkMenu(win, expectedEnabled) {
+ await SimpleTest.promiseFocus(win);
+ const toolsMenu = win.document.getElementById("tools-menu");
+ const fxViewMenuItem = toolsMenu.querySelector("#menu_openFirefoxView");
+ const menuShown = BrowserTestUtils.waitForEvent(toolsMenu, "popupshown");
+
+ toolsMenu.openMenu(true);
+ await menuShown;
+ Assert.equal(
+ BrowserTestUtils.isVisible(fxViewMenuItem),
+ expectedEnabled,
+ `Firefox view menu item is ${expectedEnabled ? "enabled" : "hidden"}`
+ );
+ const menuHidden = BrowserTestUtils.waitForEvent(toolsMenu, "popuphidden");
+ toolsMenu.menupopup.hidePopup();
+ await menuHidden;
+ }
+
+ async function checkEntryPointsInWindow(win, expectedVisible) {
+ const fxViewBtn = win.document.getElementById("firefox-view-button");
+
+ if (AppConstants.platform != "macosx") {
+ await checkMenu(win, expectedVisible);
+ }
+ // check the tab button
+ Assert.equal(
+ BrowserTestUtils.isVisible(fxViewBtn),
+ expectedVisible,
+ `#${fxViewBtn.id} is ${
+ expectedVisible ? "visible" : "hidden"
+ } as expected`
+ );
+ }
+
+ info("Check permanent private browsing");
+ // Setting permanent private browsing normally requires a restart.
+ // We'll emulate by manually setting the attribute it controls manually
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.privatebrowsing.autostart", true]],
+ });
+ const newWin = await BrowserTestUtils.openNewBrowserWindow();
+ newWin.document.documentElement.setAttribute(
+ "privatebrowsingmode",
+ "permanent"
+ );
+ await checkEntryPointsInWindow(newWin, false);
+ await BrowserTestUtils.closeWindow(newWin);
+ await SpecialPowers.popPrefEnv();
+
+ info("Check defaults (non-private)");
+ await SimpleTest.promiseFocus(window);
+ await checkEntryPointsInWindow(window, true);
+
+ info("Check private (temporary) browsing");
+ const privateWin = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ await checkEntryPointsInWindow(privateWin, false);
+ await BrowserTestUtils.closeWindow(privateWin);
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_firefoxview_virtual_list.js b/browser/components/firefoxview/tests/browser/browser_firefoxview_virtual_list.js
new file mode 100644
index 0000000000..501deb8e68
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_firefoxview_virtual_list.js
@@ -0,0 +1,85 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const VIRTUAL_LIST_ENABLED_PREF = "browser.firefox-view.virtual-list.enabled";
+
+add_setup(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [[VIRTUAL_LIST_ENABLED_PREF, true]],
+ });
+ registerCleanupFunction(async () => {
+ await SpecialPowers.popPrefEnv();
+ clearHistory();
+ });
+});
+
+add_task(async function test_max_render_count_on_win_resize() {
+ const now = new Date();
+ await PlacesUtils.history.insertMany([
+ {
+ url: "https://example.net/",
+ visits: [{ date: now }],
+ },
+ ]);
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ is(
+ document.location.href,
+ getFirefoxViewURL(),
+ "Firefox View is loaded to the Recent Browsing page."
+ );
+
+ await navigateToCategoryAndWait(document, "history");
+
+ let historyComponent = document.querySelector("view-history");
+ let tabList = historyComponent.lists[0];
+ let rootVirtualList = tabList.rootVirtualListEl;
+
+ const initialHeight = window.outerHeight;
+ const initialWidth = window.outerWidth;
+ const initialMaxRenderCount = rootVirtualList.maxRenderCountEstimate;
+ info(`The initial maxRenderCountEstimate is ${initialMaxRenderCount}`);
+ info(`The initial innerHeight is ${window.innerHeight}`);
+
+ // Resize window with new height value
+ const newHeight = 540;
+ window.resizeTo(initialWidth, newHeight);
+ await TestUtils.waitForCondition(
+ () => window.outerHeight >= newHeight,
+ `The window has been resized with outer height of ${window.outerHeight} instead of ${newHeight}.`
+ );
+ await TestUtils.waitForCondition(
+ () =>
+ rootVirtualList.updateComplete &&
+ rootVirtualList.maxRenderCountEstimate < initialMaxRenderCount,
+ `Max render count ${rootVirtualList.maxRenderCountEstimate} is not less than initial max render count ${initialMaxRenderCount}`
+ );
+ const newMaxRenderCount = rootVirtualList.maxRenderCountEstimate;
+
+ Assert.strictEqual(
+ rootVirtualList.maxRenderCountEstimate,
+ newMaxRenderCount,
+ `The maxRenderCountEstimate on the virtual-list is now ${newMaxRenderCount}`
+ );
+
+ // Restore initial window size
+ resizeTo(initialWidth, initialHeight);
+ await TestUtils.waitForCondition(
+ () =>
+ window.outerWidth >= initialHeight && window.outerWidth >= initialWidth,
+ `The window has been resized with outer height of ${window.outerHeight} instead of ${initialHeight}.`
+ );
+ info(`The final innerHeight is ${window.innerHeight}`);
+ await TestUtils.waitForCondition(
+ () =>
+ rootVirtualList.updateComplete &&
+ rootVirtualList.maxRenderCountEstimate > newMaxRenderCount,
+ `Max render count ${rootVirtualList.maxRenderCountEstimate} is not greater than new max render count ${newMaxRenderCount}`
+ );
+
+ info(
+ `The maxRenderCountEstimate on the virtual-list is greater than ${newMaxRenderCount} after window resize`
+ );
+ gBrowser.removeTab(gBrowser.selectedTab);
+ });
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_history_firefoxview.js b/browser/components/firefoxview/tests/browser/browser_history_firefoxview.js
new file mode 100644
index 0000000000..a6c697e398
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_history_firefoxview.js
@@ -0,0 +1,544 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+ChromeUtils.defineESModuleGetters(globalThis, {
+ SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs",
+});
+const { ProfileAge } = ChromeUtils.importESModule(
+ "resource://gre/modules/ProfileAge.sys.mjs"
+);
+
+const HAS_IMPORTED_HISTORY_PREF = "browser.migrate.interactions.history";
+const IMPORT_HISTORY_DISMISSED_PREF =
+ "browser.tabs.firefox-view.importHistory.dismissed";
+const HISTORY_EVENT = [["firefoxview_next", "history", "visits", undefined]];
+const SHOW_ALL_HISTORY_EVENT = [
+ ["firefoxview_next", "show_all_history", "tabs", undefined],
+];
+
+const NEVER_REMEMBER_HISTORY_PREF = "browser.privatebrowsing.autostart";
+const DAY_MS = 24 * 60 * 60 * 1000;
+const today = new Date();
+const yesterday = new Date(Date.now() - DAY_MS);
+const twoDaysAgo = new Date(Date.now() - DAY_MS * 2);
+const threeDaysAgo = new Date(Date.now() - DAY_MS * 3);
+const fourDaysAgo = new Date(Date.now() - DAY_MS * 4);
+const oneMonthAgo = new Date(today);
+
+// Set the date for the first day of the last month
+oneMonthAgo.setDate(1);
+if (oneMonthAgo.getMonth() === 0) {
+ // If today's date is in January, use first day in December from the previous year
+ oneMonthAgo.setMonth(11);
+ oneMonthAgo.setFullYear(oneMonthAgo.getFullYear() - 1);
+} else {
+ oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
+}
+
+function isElInViewport(element) {
+ const boundingRect = element.getBoundingClientRect();
+ return (
+ boundingRect.top >= 0 &&
+ boundingRect.left >= 0 &&
+ boundingRect.bottom <=
+ (window.innerHeight || document.documentElement.clientHeight) &&
+ boundingRect.right <=
+ (window.innerWidth || document.documentElement.clientWidth)
+ );
+}
+
+async function historyComponentReady(historyComponent) {
+ await TestUtils.waitForCondition(
+ () =>
+ [...historyComponent.allHistoryItems.values()].reduce(
+ (acc, { length }) => acc + length,
+ 0
+ ) === 24
+ );
+
+ let expected = historyComponent.historyMapByDate.length;
+ let actual = historyComponent.cards.length;
+
+ is(expected, actual, `Total number of cards should be ${expected}`);
+}
+
+async function historyTelemetry() {
+ await TestUtils.waitForCondition(
+ () => {
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ false
+ ).parent;
+ return events && events.length >= 1;
+ },
+ "Waiting for history firefoxview telemetry event.",
+ 200,
+ 100
+ );
+
+ TelemetryTestUtils.assertEvents(
+ HISTORY_EVENT,
+ { category: "firefoxview_next" },
+ { clear: true, process: "parent" }
+ );
+}
+
+async function sortHistoryTelemetry(sortHistoryEvent) {
+ await TestUtils.waitForCondition(
+ () => {
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ false
+ ).parent;
+ return events && events.length >= 1;
+ },
+ "Waiting for sort_history firefoxview telemetry event.",
+ 200,
+ 100
+ );
+
+ TelemetryTestUtils.assertEvents(
+ sortHistoryEvent,
+ { category: "firefoxview_next" },
+ { clear: true, process: "parent" }
+ );
+}
+
+async function showAllHistoryTelemetry() {
+ await TestUtils.waitForCondition(
+ () => {
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ false
+ ).parent;
+ return events && events.length >= 1;
+ },
+ "Waiting for show_all_history firefoxview telemetry event.",
+ 200,
+ 100
+ );
+
+ TelemetryTestUtils.assertEvents(
+ SHOW_ALL_HISTORY_EVENT,
+ { category: "firefoxview_next" },
+ { clear: true, process: "parent" }
+ );
+}
+
+async function addHistoryItems(dateAdded) {
+ await PlacesUtils.history.insert({
+ url: URLs[0],
+ title: "Example Domain 1",
+ visits: [{ date: dateAdded }],
+ });
+ await PlacesUtils.history.insert({
+ url: URLs[1],
+ title: "Example Domain 2",
+ visits: [{ date: dateAdded }],
+ });
+ await PlacesUtils.history.insert({
+ url: URLs[2],
+ title: "Example Domain 3",
+ visits: [{ date: dateAdded }],
+ });
+ await PlacesUtils.history.insert({
+ url: URLs[3],
+ title: "Example Domain 4",
+ visits: [{ date: dateAdded }],
+ });
+}
+
+add_setup(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.firefox-view.search.enabled", true]],
+ });
+ registerCleanupFunction(async () => {
+ await SpecialPowers.popPrefEnv();
+ await PlacesUtils.history.clear();
+ });
+});
+
+add_task(async function test_list_ordering() {
+ await PlacesUtils.history.clear();
+ await addHistoryItems(today);
+ await addHistoryItems(yesterday);
+ await addHistoryItems(twoDaysAgo);
+ await addHistoryItems(threeDaysAgo);
+ await addHistoryItems(fourDaysAgo);
+ await addHistoryItems(oneMonthAgo);
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+
+ await navigateToCategoryAndWait(document, "history");
+
+ let historyComponent = document.querySelector("view-history");
+ historyComponent.profileAge = 8;
+
+ await historyComponentReady(historyComponent);
+
+ let firstCard = historyComponent.cards[0];
+
+ info("The first card should have a header for 'Today'.");
+ await BrowserTestUtils.waitForMutationCondition(
+ firstCard.querySelector("[slot=header]"),
+ { attributes: true },
+ () =>
+ document.l10n.getAttributes(firstCard.querySelector("[slot=header]"))
+ .id === "firefoxview-history-date-today"
+ );
+
+ // Select first history item in first card
+ await clearAllParentTelemetryEvents();
+ await TestUtils.waitForCondition(() => {
+ return historyComponent.lists[0].rowEls.length;
+ });
+ let firstHistoryLink = historyComponent.lists[0].rowEls[0].mainEl;
+ let promiseHidden = BrowserTestUtils.waitForEvent(
+ document,
+ "visibilitychange"
+ );
+ await EventUtils.synthesizeMouseAtCenter(firstHistoryLink, {}, content);
+ await historyTelemetry();
+ await promiseHidden;
+ await openFirefoxViewTab(browser.ownerGlobal);
+
+ // Test number of cards when sorted by site/domain
+ await clearAllParentTelemetryEvents();
+ let sortHistoryEvent = [
+ [
+ "firefoxview_next",
+ "sort_history",
+ "tabs",
+ undefined,
+ { sort_type: "site", search_start: "false" },
+ ],
+ ];
+ // Select sort by site option
+ await EventUtils.synthesizeMouseAtCenter(
+ historyComponent.sortInputs[1],
+ {},
+ content
+ );
+ await TestUtils.waitForCondition(() => historyComponent.fullyUpdated);
+ await sortHistoryTelemetry(sortHistoryEvent);
+
+ let expectedNumOfCards = historyComponent.historyMapBySite.length;
+
+ info(`Total number of cards should be ${expectedNumOfCards}`);
+ await BrowserTestUtils.waitForMutationCondition(
+ historyComponent.shadowRoot,
+ { childList: true, subtree: true },
+ () => expectedNumOfCards === historyComponent.cards.length
+ );
+
+ await clearAllParentTelemetryEvents();
+ sortHistoryEvent = [
+ [
+ "firefoxview_next",
+ "sort_history",
+ "tabs",
+ undefined,
+ { sort_type: "date", search_start: "false" },
+ ],
+ ];
+ // Select sort by date option
+ await EventUtils.synthesizeMouseAtCenter(
+ historyComponent.sortInputs[0],
+ {},
+ content
+ );
+ await TestUtils.waitForCondition(() => historyComponent.fullyUpdated);
+ await sortHistoryTelemetry(sortHistoryEvent);
+
+ // clean up extra tabs
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.tabs.at(-1));
+ }
+ });
+});
+
+add_task(async function test_empty_states() {
+ await PlacesUtils.history.clear();
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+
+ await navigateToCategoryAndWait(document, "history");
+
+ let historyComponent = document.querySelector("view-history");
+ historyComponent.profileAge = 8;
+ await TestUtils.waitForCondition(() => historyComponent.emptyState);
+ let emptyStateCard = historyComponent.emptyState;
+ ok(
+ emptyStateCard.headerEl.textContent.includes(
+ "Get back to where you’ve been"
+ ),
+ "Initial empty state header has the expected text."
+ );
+ ok(
+ emptyStateCard.descriptionEls[0].textContent.includes(
+ "As you browse, the pages you visit will be listed here."
+ ),
+ "Initial empty state description has the expected text."
+ );
+
+ // Test empty state when History mode is set to never remember
+ Services.prefs.setBoolPref(NEVER_REMEMBER_HISTORY_PREF, true);
+ // Manually update the history component from the test, since changing this setting
+ // in about:preferences will require a browser reload
+ historyComponent.requestUpdate();
+ await TestUtils.waitForCondition(() => historyComponent.fullyUpdated);
+ emptyStateCard = historyComponent.emptyState;
+ ok(
+ emptyStateCard.headerEl.textContent.includes("Nothing to show"),
+ "Empty state with never remember history header has the expected text."
+ );
+ ok(
+ emptyStateCard.descriptionEls[1].textContent.includes(
+ "remember your activity as you browse. To change that"
+ ),
+ "Empty state with never remember history description has the expected text."
+ );
+ // Reset History mode to Remember
+ Services.prefs.setBoolPref(NEVER_REMEMBER_HISTORY_PREF, false);
+ // Manually update the history component from the test, since changing this setting
+ // in about:preferences will require a browser reload
+ historyComponent.requestUpdate();
+ await TestUtils.waitForCondition(() => historyComponent.fullyUpdated);
+
+ // Test import history banner shows if profile age is 7 days or less and
+ // user hasn't already imported history from another browser
+ Services.prefs.setBoolPref(IMPORT_HISTORY_DISMISSED_PREF, false);
+ Services.prefs.setBoolPref(HAS_IMPORTED_HISTORY_PREF, true);
+ ok(!historyComponent.cards.length, "Import history banner not shown yet");
+ historyComponent.profileAge = 0;
+ await TestUtils.waitForCondition(() => historyComponent.fullyUpdated);
+ ok(
+ !historyComponent.cards.length,
+ "Import history banner still not shown yet"
+ );
+ Services.prefs.setBoolPref(HAS_IMPORTED_HISTORY_PREF, false);
+ await TestUtils.waitForCondition(() => historyComponent.fullyUpdated);
+ ok(
+ historyComponent.cards[0].textContent.includes(
+ "Import history from another browser"
+ ),
+ "Import history banner is shown"
+ );
+ let importHistoryCloseButton =
+ historyComponent.cards[0].querySelector("button.close");
+ importHistoryCloseButton.click();
+ await TestUtils.waitForCondition(() => historyComponent.fullyUpdated);
+ ok(
+ Services.prefs.getBoolPref(IMPORT_HISTORY_DISMISSED_PREF, true) &&
+ !historyComponent.cards.length,
+ "Import history banner has been dismissed."
+ );
+ // Reset profileAge to greater than 7 to avoid affecting other tests
+ historyComponent.profileAge = 8;
+ Services.prefs.setBoolPref(IMPORT_HISTORY_DISMISSED_PREF, false);
+
+ gBrowser.removeTab(gBrowser.selectedTab);
+ });
+});
+
+add_task(async function test_observers_removed_when_view_is_hidden() {
+ await PlacesUtils.history.clear();
+ const NEW_TAB_URL = "https://example.com";
+ const tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ NEW_TAB_URL
+ );
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "history");
+ const historyComponent = document.querySelector("view-history");
+ historyComponent.profileAge = 8;
+ let visitList = await TestUtils.waitForCondition(() =>
+ historyComponent.cards?.[0]?.querySelector("fxview-tab-list")
+ );
+ info("The list should show a visit from the new tab.");
+ await TestUtils.waitForCondition(() => visitList.rowEls.length === 1);
+
+ let promiseHidden = BrowserTestUtils.waitForEvent(
+ document,
+ "visibilitychange"
+ );
+ await BrowserTestUtils.switchTab(gBrowser, tab);
+ await promiseHidden;
+ const { date } = await PlacesUtils.history
+ .fetch(NEW_TAB_URL, {
+ includeVisits: true,
+ })
+ .then(({ visits }) => visits[0]);
+ await addHistoryItems(date);
+ is(
+ visitList.rowEls.length,
+ 1,
+ "The list does not update when Firefox View is hidden."
+ );
+
+ info("The list should update when Firefox View is visible.");
+ await openFirefoxViewTab(browser.ownerGlobal);
+ visitList = await TestUtils.waitForCondition(() =>
+ historyComponent.cards?.[0]?.querySelector("fxview-tab-list")
+ );
+ await TestUtils.waitForCondition(() => visitList.rowEls.length > 1);
+
+ BrowserTestUtils.removeTab(tab);
+ });
+});
+
+add_task(async function test_show_all_history_telemetry() {
+ await PlacesUtils.history.clear();
+ await addHistoryItems(today);
+ await addHistoryItems(yesterday);
+ await addHistoryItems(twoDaysAgo);
+ await addHistoryItems(threeDaysAgo);
+ await addHistoryItems(fourDaysAgo);
+ await addHistoryItems(oneMonthAgo);
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+
+ await navigateToCategoryAndWait(document, "history");
+
+ let historyComponent = document.querySelector("view-history");
+ historyComponent.profileAge = 8;
+ await historyComponentReady(historyComponent);
+
+ await clearAllParentTelemetryEvents();
+ let showAllHistoryBtn = historyComponent.showAllHistoryBtn;
+ showAllHistoryBtn.scrollIntoView();
+ await EventUtils.synthesizeMouseAtCenter(showAllHistoryBtn, {}, content);
+ await showAllHistoryTelemetry();
+
+ // Make sure library window is shown
+ await TestUtils.waitForCondition(() =>
+ Services.wm.getMostRecentWindow("Places:Organizer")
+ );
+ let library = Services.wm.getMostRecentWindow("Places:Organizer");
+ await BrowserTestUtils.closeWindow(library);
+ gBrowser.removeTab(gBrowser.selectedTab);
+ });
+});
+
+add_task(async function test_search_history() {
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "history");
+ const historyComponent = document.querySelector("view-history");
+ historyComponent.profileAge = 8;
+ await historyComponentReady(historyComponent);
+ const searchTextbox = await TestUtils.waitForCondition(
+ () => historyComponent.searchTextbox,
+ "The search textbox is displayed."
+ );
+
+ info("Input a search query.");
+ EventUtils.synthesizeMouseAtCenter(searchTextbox, {}, content);
+ EventUtils.sendString("Example Domain 1", content);
+ await BrowserTestUtils.waitForMutationCondition(
+ historyComponent.shadowRoot,
+ { childList: true, subtree: true },
+ () =>
+ historyComponent.cards.length === 1 &&
+ document.l10n.getAttributes(
+ historyComponent.cards[0].querySelector("[slot=header]")
+ ).id === "firefoxview-search-results-header"
+ );
+ await TestUtils.waitForCondition(() => {
+ const { rowEls } = historyComponent.lists[0];
+ return rowEls.length === 1 && rowEls[0].mainEl.href === URLs[0];
+ }, "There is one matching search result.");
+
+ info("Input a bogus search query.");
+ EventUtils.synthesizeMouseAtCenter(searchTextbox, {}, content);
+ EventUtils.sendString("Bogus Query", content);
+ await TestUtils.waitForCondition(() => {
+ const tabList = historyComponent.lists[0];
+ return tabList?.shadowRoot.querySelector("fxview-empty-state");
+ }, "There are no matching search results.");
+
+ info("Clear the search query.");
+ EventUtils.synthesizeMouseAtCenter(searchTextbox.clearButton, {}, content);
+ await BrowserTestUtils.waitForMutationCondition(
+ historyComponent.shadowRoot,
+ { childList: true, subtree: true },
+ () =>
+ historyComponent.cards.length ===
+ historyComponent.historyMapByDate.length
+ );
+ searchTextbox.blur();
+
+ info("Input a bogus search query with keyboard.");
+ EventUtils.synthesizeKey("f", { accelKey: true }, content);
+ EventUtils.sendString("Bogus Query", content);
+ await TestUtils.waitForCondition(() => {
+ const tabList = historyComponent.lists[0];
+ return tabList?.shadowRoot.querySelector("fxview-empty-state");
+ }, "There are no matching search results.");
+
+ info("Clear the search query with keyboard.");
+ is(
+ historyComponent.shadowRoot.activeElement,
+ searchTextbox,
+ "Search input is focused"
+ );
+ EventUtils.synthesizeKey("KEY_Tab", {}, content);
+ ok(
+ searchTextbox.clearButton.matches(":focus-visible"),
+ "Clear Search button is focused"
+ );
+ EventUtils.synthesizeKey("KEY_Enter", {}, content);
+ await BrowserTestUtils.waitForMutationCondition(
+ historyComponent.shadowRoot,
+ { childList: true, subtree: true },
+ () =>
+ historyComponent.cards.length ===
+ historyComponent.historyMapByDate.length
+ );
+ });
+});
+
+add_task(async function test_persist_collapse_card_after_view_change() {
+ await PlacesUtils.history.clear();
+ await addHistoryItems(today);
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "history");
+ const historyComponent = document.querySelector("view-history");
+ historyComponent.profileAge = 8;
+ await TestUtils.waitForCondition(
+ () =>
+ [...historyComponent.allHistoryItems.values()].reduce(
+ (acc, { length }) => acc + length,
+ 0
+ ) === 4
+ );
+ let firstHistoryCard = historyComponent.cards[0];
+ ok(
+ firstHistoryCard.isExpanded,
+ "The first history card is expanded initially."
+ );
+
+ // Collapse history card
+ EventUtils.synthesizeMouseAtCenter(firstHistoryCard.summaryEl, {}, content);
+ is(
+ firstHistoryCard.detailsEl.hasAttribute("open"),
+ false,
+ "The first history card is now collapsed."
+ );
+
+ // Switch to a new view and then back to History
+ await navigateToCategoryAndWait(document, "syncedtabs");
+ await navigateToCategoryAndWait(document, "history");
+
+ // Check that first history card is still collapsed after changing view
+ ok(
+ !firstHistoryCard.isExpanded,
+ "The first history card is still collapsed after changing view."
+ );
+
+ await PlacesUtils.history.clear();
+ gBrowser.removeTab(gBrowser.selectedTab);
+ });
+});
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..0fa747d40f
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_notification_dot.js
@@ -0,0 +1,392 @@
+/* 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",
+ tabs: [],
+ },
+ {
+ id: 2,
+ name: "My iphone",
+ type: "mobile",
+ tabs: [],
+ },
+ ]);
+
+ 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);
+ }
+ Assert.greater(
+ 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.isVisible(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_opentabs_cards.js b/browser/components/firefoxview/tests/browser/browser_opentabs_cards.js
new file mode 100644
index 0000000000..d57aa3cad1
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_opentabs_cards.js
@@ -0,0 +1,628 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const TEST_URL = "about:robots";
+const ROW_URL_ID = "fxview-tab-row-url";
+const ROW_DATE_ID = "fxview-tab-row-date";
+
+let gInitialTab;
+let gInitialTabURL;
+const { NonPrivateTabs } = ChromeUtils.importESModule(
+ "resource:///modules/OpenTabs.sys.mjs"
+);
+
+add_setup(function () {
+ // This test opens a lot of windows and tabs and might run long on slower configurations
+ requestLongerTimeout(2);
+ gInitialTab = gBrowser.selectedTab;
+ gInitialTabURL = gBrowser.selectedBrowser.currentURI.spec;
+});
+
+async function navigateToOpenTabs(browser) {
+ const document = browser.contentDocument;
+ if (document.querySelector("named-deck").selectedViewName != "opentabs") {
+ await navigateToCategoryAndWait(browser.contentDocument, "opentabs");
+ }
+}
+
+function getOpenTabsComponent(browser) {
+ return browser.contentDocument.querySelector("named-deck > view-opentabs");
+}
+
+function getCards(browser) {
+ return getOpenTabsComponent(browser).shadowRoot.querySelectorAll(
+ "view-opentabs-card"
+ );
+}
+
+async function cleanup() {
+ await SimpleTest.promiseFocus(window);
+ await promiseAllButPrimaryWindowClosed();
+ await BrowserTestUtils.switchTab(gBrowser, gInitialTab);
+ await closeFirefoxViewTab(window);
+
+ // clean up extra tabs
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.tabs.at(-1));
+ }
+
+ is(
+ BrowserWindowTracker.orderedWindows.length,
+ 1,
+ "One window at the end of test cleanup"
+ );
+ Assert.deepEqual(
+ gBrowser.tabs.map(tab => tab.linkedBrowser.currentURI.spec),
+ [gInitialTabURL],
+ "One about:blank tab open at the end up test cleanup"
+ );
+}
+
+async function getRowsForCard(card) {
+ await TestUtils.waitForCondition(() => card.tabList.rowEls.length);
+ return card.tabList.rowEls;
+}
+
+/**
+ * Verify that there are the expected number of cards, and that each card has
+ * the expected URLs in order.
+ *
+ * @param {tabbrowser} browser
+ * The browser to verify in.
+ * @param {string[][]} expected
+ * The expected URLs for each card.
+ */
+async function checkTabLists(browser, expected) {
+ const cards = getCards(browser);
+ is(cards.length, expected.length, `There are ${expected.length} windows.`);
+ for (let i = 0; i < cards.length; i++) {
+ const tabItems = await getRowsForCard(cards[i]);
+ const actual = Array.from(tabItems).map(({ url }) => url);
+ Assert.deepEqual(
+ actual,
+ expected[i],
+ "Tab list has items with URLs in the expected order"
+ );
+ }
+}
+
+add_task(async function open_tab_same_window() {
+ await openFirefoxViewTab(window).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ await navigateToOpenTabs(browser);
+ const openTabs = getOpenTabsComponent(browser);
+ await openTabs.openTabsTarget.readyWindowsPromise;
+ await openTabs.updateComplete;
+
+ await checkTabLists(browser, [[gInitialTabURL]]);
+ let promiseHidden = BrowserTestUtils.waitForEvent(
+ browser.contentDocument,
+ "visibilitychange"
+ );
+ let tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabChange"
+ );
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+ await promiseHidden;
+ await tabChangeRaised;
+ });
+
+ const [originalTab, newTab] = gBrowser.visibleTabs;
+
+ await openFirefoxViewTab(window).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ const openTabs = getOpenTabsComponent(browser);
+ setSortOption(openTabs, "tabStripOrder");
+ await openTabs.openTabsTarget.readyWindowsPromise;
+ await openTabs.updateComplete;
+
+ await checkTabLists(browser, [[gInitialTabURL, TEST_URL]]);
+ let promiseHidden = BrowserTestUtils.waitForEvent(
+ browser.contentDocument,
+ "visibilitychange"
+ );
+ const cards = getCards(browser);
+ const tabItems = await getRowsForCard(cards[0]);
+ tabItems[0].mainEl.click();
+ await promiseHidden;
+ });
+
+ await BrowserTestUtils.waitForCondition(
+ () => originalTab.selected,
+ "The original tab is selected."
+ );
+
+ await openFirefoxViewTab(window).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ const cards = getCards(browser);
+ let tabItems = await getRowsForCard(cards[0]);
+
+ let promiseHidden = BrowserTestUtils.waitForEvent(
+ browser.contentDocument,
+ "visibilitychange"
+ );
+
+ tabItems[1].mainEl.click();
+ await promiseHidden;
+ });
+
+ await BrowserTestUtils.waitForCondition(
+ () => newTab.selected,
+ "The new tab is selected."
+ );
+
+ await openFirefoxViewTab(window).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ let tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabChange"
+ );
+
+ info("Bring the new tab to the front.");
+ gBrowser.moveTabTo(newTab, 0);
+
+ await tabChangeRaised;
+ await checkTabLists(browser, [[TEST_URL, gInitialTabURL]]);
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabChange"
+ );
+ await BrowserTestUtils.removeTab(newTab);
+ await tabChangeRaised;
+
+ await checkTabLists(browser, [[gInitialTabURL]]);
+ const [card] = getCards(browser);
+ const [row] = await getRowsForCard(card);
+ ok(
+ !row.shadowRoot.getElementById("fxview-tab-row-url").hidden,
+ "The URL is displayed, since we have one window."
+ );
+ ok(
+ !row.shadowRoot.getElementById("fxview-tab-row-date").hidden,
+ "The date is displayed, since we have one window."
+ );
+ });
+
+ await cleanup();
+});
+
+add_task(async function open_tab_new_window() {
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ let winFocused;
+ await BrowserTestUtils.openNewForegroundTab(win.gBrowser, TEST_URL);
+
+ info("Open fxview in new window");
+ await openFirefoxViewTab(win).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ await navigateToOpenTabs(browser);
+ const openTabs = getOpenTabsComponent(browser);
+ setSortOption(openTabs, "tabStripOrder");
+ await openTabs.openTabsTarget.readyWindowsPromise;
+ await openTabs.updateComplete;
+
+ await checkTabLists(browser, [
+ [gInitialTabURL, TEST_URL],
+ [gInitialTabURL],
+ ]);
+ const cards = getCards(browser);
+ const originalWinRows = await getRowsForCard(cards[1]);
+ const [row] = originalWinRows;
+ ok(
+ row.shadowRoot.getElementById("fxview-tab-row-url").hidden,
+ "The URL is hidden, since we have two windows."
+ );
+ ok(
+ row.shadowRoot.getElementById("fxview-tab-row-date").hidden,
+ "The date is hidden, since we have two windows."
+ );
+ info("Select a tab from the original window.");
+ let tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabRecencyChange"
+ );
+ winFocused = BrowserTestUtils.waitForEvent(window, "focus", true);
+ originalWinRows[0].mainEl.click();
+ await tabChangeRaised;
+ });
+
+ info("Wait for the original window to be focused");
+ await winFocused;
+
+ await openFirefoxViewTab(window).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ await navigateToOpenTabs(browser);
+ const openTabs = getOpenTabsComponent(browser);
+ await openTabs.openTabsTarget.readyWindowsPromise;
+ await openTabs.updateComplete;
+
+ const cards = getCards(browser);
+ is(cards.length, 2, "There are two windows.");
+ const newWinRows = await getRowsForCard(cards[1]);
+
+ info("Select a tab from the new window.");
+ winFocused = BrowserTestUtils.waitForEvent(win, "focus", true);
+ let tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabRecencyChange"
+ );
+ newWinRows[0].mainEl.click();
+ await tabChangeRaised;
+ });
+ info("Wait for the new window to be focused");
+ await winFocused;
+ await cleanup();
+});
+
+add_task(async function open_tab_new_private_window() {
+ await BrowserTestUtils.openNewBrowserWindow({ private: true });
+
+ await SimpleTest.promiseFocus(window);
+ await openFirefoxViewTab(window).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ await navigateToOpenTabs(browser);
+ const openTabs = getOpenTabsComponent(browser);
+ await openTabs.openTabsTarget.readyWindowsPromise;
+ await openTabs.updateComplete;
+
+ const cards = getCards(browser);
+ is(cards.length, 1, "The private window is not displayed.");
+ });
+ await cleanup();
+});
+
+add_task(async function open_tab_new_window_sort_by_recency() {
+ info("Open new tabs in a new window.");
+ const newWindow = await BrowserTestUtils.openNewBrowserWindow();
+ const tabs = [
+ newWindow.gBrowser.selectedTab,
+ await BrowserTestUtils.openNewForegroundTab(newWindow.gBrowser, URLs[0]),
+ await BrowserTestUtils.openNewForegroundTab(newWindow.gBrowser, URLs[1]),
+ ];
+
+ info("Open Firefox View in the original window.");
+ await openFirefoxViewTab(window).then(async ({ linkedBrowser }) => {
+ await navigateToOpenTabs(linkedBrowser);
+ const openTabs = getOpenTabsComponent(linkedBrowser);
+ setSortOption(openTabs, "recency");
+ await openTabs.openTabsTarget.readyWindowsPromise;
+ await openTabs.updateComplete;
+
+ await checkTabLists(linkedBrowser, [
+ [gInitialTabURL],
+ [URLs[1], URLs[0], gInitialTabURL],
+ ]);
+ info("Select tabs in the new window to trigger recency changes.");
+ await SimpleTest.promiseFocus(newWindow);
+ await BrowserTestUtils.switchTab(newWindow.gBrowser, tabs[1]);
+ await BrowserTestUtils.switchTab(newWindow.gBrowser, tabs[0]);
+ await SimpleTest.promiseFocus(window);
+ await TestUtils.waitForCondition(async () => {
+ const [, secondCard] = getCards(linkedBrowser);
+ const tabItems = await getRowsForCard(secondCard);
+ return tabItems[0].url === gInitialTabURL;
+ });
+ await checkTabLists(linkedBrowser, [
+ [gInitialTabURL],
+ [gInitialTabURL, URLs[0], URLs[1]],
+ ]);
+ });
+ await cleanup();
+});
+
+add_task(async function styling_for_multiple_windows() {
+ await openFirefoxViewTab(window).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ await navigateToOpenTabs(browser);
+ const openTabs = getOpenTabsComponent(browser);
+ setSortOption(openTabs, "tabStripOrder");
+ await openTabs.openTabsTarget.readyWindowsPromise;
+ await openTabs.updateComplete;
+
+ ok(
+ openTabs.shadowRoot.querySelector("[card-count=one]"),
+ "The container shows one column when one window is open."
+ );
+ });
+
+ await BrowserTestUtils.openNewBrowserWindow();
+ let tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabChange"
+ );
+ await NonPrivateTabs.readyWindowsPromise;
+ await tabChangeRaised;
+ is(
+ NonPrivateTabs.currentWindows.length,
+ 2,
+ "NonPrivateTabs now has 2 currentWindows"
+ );
+
+ info("switch to firefox view in the first window");
+ SimpleTest.promiseFocus(window);
+ await openFirefoxViewTab(window).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ const openTabs = getOpenTabsComponent(browser);
+ await openTabs.openTabsTarget.readyWindowsPromise;
+ await openTabs.updateComplete;
+ is(
+ openTabs.openTabsTarget.currentWindows.length,
+ 2,
+ "There should be 2 current windows"
+ );
+ ok(
+ openTabs.shadowRoot.querySelector("[card-count=two]"),
+ "The container shows two columns when two windows are open."
+ );
+ });
+ await BrowserTestUtils.openNewBrowserWindow();
+ tabChangeRaised = BrowserTestUtils.waitForEvent(NonPrivateTabs, "TabChange");
+ await NonPrivateTabs.readyWindowsPromise;
+ await tabChangeRaised;
+ is(
+ NonPrivateTabs.currentWindows.length,
+ 3,
+ "NonPrivateTabs now has 2 currentWindows"
+ );
+
+ SimpleTest.promiseFocus(window);
+ await openFirefoxViewTab(window).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ const openTabs = getOpenTabsComponent(browser);
+ await openTabs.openTabsTarget.readyWindowsPromise;
+ await openTabs.updateComplete;
+
+ ok(
+ openTabs.shadowRoot.querySelector("[card-count=three-or-more]"),
+ "The container shows three columns when three windows are open."
+ );
+ });
+ await cleanup();
+});
+
+add_task(async function toggle_show_more_link() {
+ const tabEntry = url => ({
+ entries: [{ url, triggeringPrincipal_base64 }],
+ });
+ const NUMBER_OF_WINDOWS = 4;
+ const NUMBER_OF_TABS = 42;
+ const browserState = { windows: [] };
+ for (let windowIndex = 0; windowIndex < NUMBER_OF_WINDOWS; windowIndex++) {
+ const winData = { tabs: [] };
+ let tabCount = windowIndex == NUMBER_OF_WINDOWS - 1 ? NUMBER_OF_TABS : 1;
+ for (let i = 0; i < tabCount; i++) {
+ winData.tabs.push(tabEntry(`data:,Window${windowIndex}-Tab${i}`));
+ }
+ winData.selected = winData.tabs.length;
+ browserState.windows.push(winData);
+ }
+ // use Session restore to batch-open windows and tabs
+ await SessionStoreTestUtils.promiseBrowserState(browserState);
+ // restoring this state requires an update to the initial tab globals
+ // so cleanup expects the right thing
+ gInitialTab = gBrowser.selectedTab;
+ gInitialTabURL = gBrowser.selectedBrowser.currentURI.spec;
+
+ const windows = Array.from(Services.wm.getEnumerator("navigator:browser"));
+ is(windows.length, NUMBER_OF_WINDOWS, "There are four browser windows.");
+
+ const tab = (win = window) => {
+ info("Tab");
+ EventUtils.synthesizeKey("KEY_Tab", {}, win);
+ };
+
+ const enter = (win = window) => {
+ info("Enter");
+ EventUtils.synthesizeKey("KEY_Enter", {}, win);
+ };
+
+ let lastCard;
+
+ SimpleTest.promiseFocus(window);
+ await openFirefoxViewTab(window).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ await navigateToOpenTabs(browser);
+ const openTabs = getOpenTabsComponent(browser);
+ await openTabs.openTabsTarget.readyWindowsPromise;
+ await openTabs.updateComplete;
+
+ const cards = getCards(browser);
+ is(cards.length, NUMBER_OF_WINDOWS, "There are four windows.");
+ lastCard = cards[NUMBER_OF_WINDOWS - 1];
+ });
+
+ await openFirefoxViewTab(window).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ const openTabs = getOpenTabsComponent(browser);
+ await openTabs.openTabsTarget.readyWindowsPromise;
+ await openTabs.updateComplete;
+ Assert.less(
+ (await getRowsForCard(lastCard)).length,
+ NUMBER_OF_TABS,
+ "Not all tabs are shown yet."
+ );
+ info("Toggle the Show More link.");
+ lastCard.shadowRoot.querySelector("div[slot=footer]").click();
+ await BrowserTestUtils.waitForMutationCondition(
+ lastCard.shadowRoot,
+ { childList: true, subtree: true },
+ async () => (await getRowsForCard(lastCard)).length === NUMBER_OF_TABS
+ );
+
+ info("Toggle the Show Less link.");
+ lastCard.shadowRoot.querySelector("div[slot=footer]").click();
+ await BrowserTestUtils.waitForMutationCondition(
+ lastCard.shadowRoot,
+ { childList: true, subtree: true },
+ async () => (await getRowsForCard(lastCard)).length < NUMBER_OF_TABS
+ );
+
+ // Setting this pref allows the test to run as expected with a keyboard on MacOS
+ await SpecialPowers.pushPrefEnv({
+ set: [["accessibility.tabfocus", 7]],
+ });
+
+ info("Toggle the Show More link with keyboard.");
+ lastCard.shadowRoot.querySelector("card-container").summaryEl.focus();
+ // Tab to first item in the list
+ tab();
+ // Tab to the footer
+ tab();
+ enter();
+ await BrowserTestUtils.waitForMutationCondition(
+ lastCard.shadowRoot,
+ { childList: true, subtree: true },
+ async () => (await getRowsForCard(lastCard)).length === NUMBER_OF_TABS
+ );
+
+ info("Toggle the Show Less link with keyboard.");
+ lastCard.shadowRoot.querySelector("card-container").summaryEl.focus();
+ // Tab to first item in the list
+ tab();
+ // Tab to the footer
+ tab();
+ enter();
+ await BrowserTestUtils.waitForMutationCondition(
+ lastCard.shadowRoot,
+ { childList: true, subtree: true },
+ async () => (await getRowsForCard(lastCard)).length < NUMBER_OF_TABS
+ );
+
+ await SpecialPowers.popPrefEnv();
+ });
+ await cleanup();
+});
+
+add_task(async function search_open_tabs() {
+ // Open a new window and navigate to TEST_URL. Then, when we search for
+ // TEST_URL, it should show a search result in the new window's card.
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ await BrowserTestUtils.openNewForegroundTab(win.gBrowser, TEST_URL);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.firefox-view.search.enabled", true]],
+ });
+ await openFirefoxViewTab(window).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ await navigateToOpenTabs(browser);
+ const openTabs = getOpenTabsComponent(browser);
+ await openTabs.openTabsTarget.readyWindowsPromise;
+ await openTabs.updateComplete;
+
+ const cards = getCards(browser);
+ is(cards.length, 2, "There are two windows.");
+ const winTabs = await getRowsForCard(cards[0]);
+ const newWinTabs = await getRowsForCard(cards[1]);
+
+ info("Input a search query.");
+ EventUtils.synthesizeMouseAtCenter(openTabs.searchTextbox, {}, content);
+ EventUtils.sendString(TEST_URL, content);
+ await TestUtils.waitForCondition(
+ () => openTabs.viewCards[0].tabList.rowEls.length === 0,
+ "There are no matching search results in the original window."
+ );
+ await TestUtils.waitForCondition(
+ () => openTabs.viewCards[1].tabList.rowEls.length === 1,
+ "There is one matching search result in the new window."
+ );
+
+ info("Clear the search query.");
+ EventUtils.synthesizeMouseAtCenter(
+ openTabs.searchTextbox.clearButton,
+ {},
+ content
+ );
+ await TestUtils.waitForCondition(
+ () => openTabs.viewCards[0].tabList.rowEls.length === winTabs.length,
+ "The original window's list is restored."
+ );
+ await TestUtils.waitForCondition(
+ () => openTabs.viewCards[1].tabList.rowEls.length === newWinTabs.length,
+ "The new window's list is restored."
+ );
+ openTabs.searchTextbox.blur();
+
+ info("Input a search query with keyboard.");
+ EventUtils.synthesizeKey("f", { accelKey: true }, content);
+ EventUtils.sendString(TEST_URL, content);
+ await TestUtils.waitForCondition(
+ () => openTabs.viewCards[0].tabList.rowEls.length === 0,
+ "There are no matching search results in the original window."
+ );
+ await TestUtils.waitForCondition(
+ () => openTabs.viewCards[1].tabList.rowEls.length === 1,
+ "There is one matching search result in the new window."
+ );
+
+ info("Clear the search query with keyboard.");
+ is(
+ openTabs.shadowRoot.activeElement,
+ openTabs.searchTextbox,
+ "Search input is focused"
+ );
+ EventUtils.synthesizeKey("KEY_Tab", {}, content);
+ ok(
+ openTabs.searchTextbox.clearButton.matches(":focus-visible"),
+ "Clear Search button is focused"
+ );
+ EventUtils.synthesizeKey("KEY_Enter", {}, content);
+ await TestUtils.waitForCondition(
+ () => openTabs.viewCards[0].tabList.rowEls.length === winTabs.length,
+ "The original window's list is restored."
+ );
+ await TestUtils.waitForCondition(
+ () => openTabs.viewCards[1].tabList.rowEls.length === newWinTabs.length,
+ "The new window's list is restored."
+ );
+ });
+
+ await SpecialPowers.popPrefEnv();
+ await cleanup();
+});
+
+add_task(async function search_open_tabs_recent_browsing() {
+ const NUMBER_OF_TABS = 6;
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ for (let i = 0; i < NUMBER_OF_TABS; i++) {
+ await BrowserTestUtils.openNewForegroundTab(win.gBrowser, TEST_URL);
+ }
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.firefox-view.search.enabled", true]],
+ });
+ await openFirefoxViewTab(window).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ await navigateToCategoryAndWait(browser.contentDocument, "recentbrowsing");
+ const recentBrowsing = browser.contentDocument.querySelector(
+ "view-recentbrowsing"
+ );
+
+ info("Input a search query.");
+ EventUtils.synthesizeMouseAtCenter(
+ recentBrowsing.searchTextbox,
+ {},
+ content
+ );
+ EventUtils.sendString(TEST_URL, content);
+ const slot = recentBrowsing.querySelector("[slot='opentabs']");
+ await TestUtils.waitForCondition(
+ () => slot.viewCards[0].tabList.rowEls.length === 5,
+ "Not all search results are shown yet."
+ );
+
+ info("Click the Show All link.");
+ const showAllLink = await TestUtils.waitForCondition(() => {
+ const elt = slot.viewCards[0].shadowRoot.querySelector(
+ "[data-l10n-id='firefoxview-show-all']"
+ );
+ EventUtils.synthesizeMouseAtCenter(elt, {}, content);
+ if (slot.viewCards[0].tabList.rowEls.length === NUMBER_OF_TABS) {
+ return elt;
+ }
+ return false;
+ }, "All search results are shown.");
+ is(showAllLink.role, "link", "The show all control is a link.");
+ ok(BrowserTestUtils.isHidden(showAllLink), "The show all link is hidden.");
+ });
+ await SpecialPowers.popPrefEnv();
+ await cleanup();
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_opentabs_changes.js b/browser/components/firefoxview/tests/browser/browser_opentabs_changes.js
new file mode 100644
index 0000000000..c293afa8cd
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_opentabs_changes.js
@@ -0,0 +1,541 @@
+const { NonPrivateTabs, getTabsTargetForWindow } = ChromeUtils.importESModule(
+ "resource:///modules/OpenTabs.sys.mjs"
+);
+let privateTabsChanges;
+
+const tabURL1 = "data:text/html,<title>Tab1</title>Tab1";
+const tabURL2 = "data:text/html,<title>Tab2</title>Tab2";
+const tabURL3 = "data:text/html,<title>Tab3</title>Tab3";
+const tabURL4 = "data:text/html,<title>Tab4</title>Tab4";
+
+const nonPrivateListener = sinon.stub();
+const privateListener = sinon.stub();
+
+function tabUrl(tab) {
+ return tab.linkedBrowser.currentURI?.spec;
+}
+
+function getWindowId(win) {
+ return win.windowGlobalChild.innerWindowId;
+}
+
+async function setup(tabChangeEventName) {
+ nonPrivateListener.resetHistory();
+ privateListener.resetHistory();
+
+ NonPrivateTabs.addEventListener(tabChangeEventName, nonPrivateListener);
+
+ await TestUtils.waitForTick();
+ is(
+ NonPrivateTabs.currentWindows.length,
+ 1,
+ "NonPrivateTabs has 1 window a tick after adding the event listener"
+ );
+
+ info("Opening new windows");
+ let win0 = window,
+ win1 = await BrowserTestUtils.openNewBrowserWindow(),
+ privateWin = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ BrowserTestUtils.startLoadingURIString(
+ win1.gBrowser.selectedBrowser,
+ tabURL1
+ );
+ await BrowserTestUtils.browserLoaded(win1.gBrowser.selectedBrowser);
+
+ // load a tab with a title/label we can easily verify
+ BrowserTestUtils.startLoadingURIString(
+ privateWin.gBrowser.selectedBrowser,
+ tabURL2
+ );
+ await BrowserTestUtils.browserLoaded(privateWin.gBrowser.selectedBrowser);
+
+ is(
+ win1.gBrowser.selectedTab.label,
+ "Tab1",
+ "Check the tab label in the new non-private window"
+ );
+ is(
+ privateWin.gBrowser.selectedTab.label,
+ "Tab2",
+ "Check the tab label in the new private window"
+ );
+
+ privateTabsChanges = getTabsTargetForWindow(privateWin);
+ privateTabsChanges.addEventListener(tabChangeEventName, privateListener);
+ is(
+ privateTabsChanges,
+ getTabsTargetForWindow(privateWin),
+ "getTabsTargetForWindow reuses a single instance per exclusive window"
+ );
+
+ await TestUtils.waitForTick();
+ is(
+ NonPrivateTabs.currentWindows.length,
+ 2,
+ "NonPrivateTabs has 2 windows once openNewBrowserWindow resolves"
+ );
+ is(
+ privateTabsChanges.currentWindows.length,
+ 1,
+ "privateTabsChanges has 1 window once openNewBrowserWindow resolves"
+ );
+
+ await SimpleTest.promiseFocus(win0);
+ info("setup, win0 has id: " + getWindowId(win0));
+ info("setup, win1 has id: " + getWindowId(win1));
+ info("setup, privateWin has id: " + getWindowId(privateWin));
+ info("setup,waiting for both private and nonPrivateListener to be called");
+ await TestUtils.waitForCondition(() => {
+ return nonPrivateListener.called && privateListener.called;
+ });
+ nonPrivateListener.resetHistory();
+ privateListener.resetHistory();
+
+ const cleanup = async eventName => {
+ NonPrivateTabs.removeEventListener(eventName, nonPrivateListener);
+ privateTabsChanges.removeEventListener(eventName, privateListener);
+ await SimpleTest.promiseFocus(window);
+ await promiseAllButPrimaryWindowClosed();
+ };
+ return { windows: [win0, win1, privateWin], cleanup };
+}
+
+add_task(async function test_TabChanges() {
+ const { windows, cleanup } = await setup("TabChange");
+ const [win0, win1, privateWin] = windows;
+ let tabChangeRaised;
+ let changeEvent;
+
+ info(
+ "Verify that manipulating tabs in a non-private window dispatches events on the correct target"
+ );
+ for (let win of [win0, win1]) {
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabChange"
+ );
+ let newTab = await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ tabURL1
+ );
+ changeEvent = await tabChangeRaised;
+ Assert.deepEqual(
+ changeEvent.detail.windowIds,
+ [getWindowId(win)],
+ "The event had the correct window id"
+ );
+
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabChange"
+ );
+ const navigateUrl = "https://example.org/";
+ BrowserTestUtils.startLoadingURIString(newTab.linkedBrowser, navigateUrl);
+ await BrowserTestUtils.browserLoaded(
+ newTab.linkedBrowser,
+ null,
+ navigateUrl
+ );
+ // navigation in a tab changes the label which should produce a change event
+ changeEvent = await tabChangeRaised;
+ Assert.deepEqual(
+ changeEvent.detail.windowIds,
+ [getWindowId(win)],
+ "The event had the correct window id"
+ );
+
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabChange"
+ );
+ BrowserTestUtils.removeTab(newTab);
+ // navigation in a tab changes the label which should produce a change event
+ changeEvent = await tabChangeRaised;
+ Assert.deepEqual(
+ changeEvent.detail.windowIds,
+ [getWindowId(win)],
+ "The event had the correct window id"
+ );
+ }
+
+ info(
+ "make sure a change to a private window doesnt dispatch on a nonprivate target"
+ );
+ nonPrivateListener.resetHistory();
+ privateListener.resetHistory();
+
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ privateTabsChanges,
+ "TabChange"
+ );
+ BrowserTestUtils.addTab(privateWin.gBrowser, tabURL1);
+ changeEvent = await tabChangeRaised;
+ info(
+ `Check windowIds adding tab to private window: ${getWindowId(
+ privateWin
+ )}: ${JSON.stringify(changeEvent.detail.windowIds)}`
+ );
+ Assert.deepEqual(
+ changeEvent.detail.windowIds,
+ [getWindowId(privateWin)],
+ "The event had the correct window id"
+ );
+ await TestUtils.waitForTick();
+ Assert.ok(
+ nonPrivateListener.notCalled,
+ "A private tab change shouldnt raise a tab change event on the non-private target"
+ );
+
+ info("testTabChanges complete");
+ await cleanup("TabChange");
+});
+
+add_task(async function test_TabRecencyChange() {
+ const { windows, cleanup } = await setup("TabRecencyChange");
+ const [win0, win1, privateWin] = windows;
+
+ let tabChangeRaised;
+ let changeEvent;
+ let sortedTabs;
+
+ info("Open some tabs in the non-private windows");
+ for (let win of [win0, win1]) {
+ for (let url of [tabURL1, tabURL2]) {
+ let tab = BrowserTestUtils.addTab(win.gBrowser, url);
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabChange"
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ await tabChangeRaised;
+ }
+ }
+
+ info("Verify switching tabs produces the expected event and result");
+ nonPrivateListener.resetHistory();
+ privateListener.resetHistory();
+
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabRecencyChange"
+ );
+ BrowserTestUtils.switchTab(win0.gBrowser, win0.gBrowser.tabs.at(-1));
+ changeEvent = await tabChangeRaised;
+
+ Assert.deepEqual(
+ changeEvent.detail.windowIds,
+ [getWindowId(win0)],
+ "The recency change event had the correct window id"
+ );
+ Assert.ok(
+ nonPrivateListener.called,
+ "Sanity check that the non-private tabs listener was called"
+ );
+ Assert.ok(
+ privateListener.notCalled,
+ "The private tabs listener was not called"
+ );
+
+ sortedTabs = NonPrivateTabs.getRecentTabs();
+ is(
+ sortedTabs[0],
+ win0.gBrowser.selectedTab,
+ "The most-recent tab is the selected tab"
+ );
+
+ info("Verify switching window produces the expected event and result");
+ nonPrivateListener.resetHistory();
+ privateListener.resetHistory();
+
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabRecencyChange"
+ );
+ await SimpleTest.promiseFocus(win1);
+ changeEvent = await tabChangeRaised;
+ Assert.deepEqual(
+ changeEvent.detail.windowIds,
+ [getWindowId(win1)],
+ "The recency change event had the correct window id"
+ );
+ Assert.ok(
+ nonPrivateListener.called,
+ "Sanity check that the non-private tabs listener was called"
+ );
+ Assert.ok(
+ privateListener.notCalled,
+ "The private tabs listener was not called"
+ );
+
+ sortedTabs = NonPrivateTabs.getRecentTabs();
+ is(
+ sortedTabs[0],
+ win1.gBrowser.selectedTab,
+ "The most-recent tab is the selected tab in the current window"
+ );
+
+ info("Verify behavior with private window changes");
+ nonPrivateListener.resetHistory();
+ privateListener.resetHistory();
+
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ privateTabsChanges,
+ "TabRecencyChange"
+ );
+ await SimpleTest.promiseFocus(privateWin);
+ changeEvent = await tabChangeRaised;
+ Assert.deepEqual(
+ changeEvent.detail.windowIds,
+ [getWindowId(privateWin)],
+ "The recency change event had the correct window id"
+ );
+ Assert.ok(
+ nonPrivateListener.notCalled,
+ "The non-private listener got no recency-change events from the private window"
+ );
+ Assert.ok(
+ privateListener.called,
+ "Sanity check the private tabs listener was called"
+ );
+
+ sortedTabs = privateTabsChanges.getRecentTabs();
+ is(
+ sortedTabs[0],
+ privateWin.gBrowser.selectedTab,
+ "The most-recent tab is the selected tab in the current window"
+ );
+ sortedTabs = NonPrivateTabs.getRecentTabs();
+ is(
+ sortedTabs[0],
+ win1.gBrowser.selectedTab,
+ "The most-recent non-private tab is still the selected tab in the previous non-private window"
+ );
+
+ info("Verify adding a tab to a private window does the right thing");
+ nonPrivateListener.resetHistory();
+ privateListener.resetHistory();
+
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ privateTabsChanges,
+ "TabRecencyChange"
+ );
+ await BrowserTestUtils.openNewForegroundTab(privateWin.gBrowser, tabURL3);
+ changeEvent = await tabChangeRaised;
+ Assert.deepEqual(
+ changeEvent.detail.windowIds,
+ [getWindowId(privateWin)],
+ "The event had the correct window id"
+ );
+ Assert.ok(
+ nonPrivateListener.notCalled,
+ "The non-private listener got no recency-change events from the private window"
+ );
+ sortedTabs = privateTabsChanges.getRecentTabs();
+ is(
+ tabUrl(sortedTabs[0]),
+ tabURL3,
+ "The most-recent tab is the tab we just opened in the private window"
+ );
+
+ nonPrivateListener.resetHistory();
+ privateListener.resetHistory();
+
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ privateTabsChanges,
+ "TabRecencyChange"
+ );
+ BrowserTestUtils.switchTab(privateWin.gBrowser, privateWin.gBrowser.tabs[0]);
+ changeEvent = await tabChangeRaised;
+ Assert.deepEqual(
+ changeEvent.detail.windowIds,
+ [getWindowId(privateWin)],
+ "The event had the correct window id"
+ );
+ Assert.ok(
+ nonPrivateListener.notCalled,
+ "The non-private listener got no recency-change events from the private window"
+ );
+ sortedTabs = privateTabsChanges.getRecentTabs();
+ is(
+ sortedTabs[0],
+ privateWin.gBrowser.selectedTab,
+ "The most-recent tab is the selected tab in the private window"
+ );
+
+ info("Verify switching back to a non-private does the right thing");
+ nonPrivateListener.resetHistory();
+ privateListener.resetHistory();
+
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabRecencyChange"
+ );
+ await SimpleTest.promiseFocus(win1);
+ await tabChangeRaised;
+ if (privateListener.called) {
+ info(`The private listener was called ${privateListener.callCount} times`);
+ }
+ Assert.ok(
+ privateListener.notCalled,
+ "The private listener got no recency-change events for the non-private window"
+ );
+ Assert.ok(
+ nonPrivateListener.called,
+ "Sanity-check the non-private listener got a recency-change event for the non-private window"
+ );
+
+ sortedTabs = privateTabsChanges.getRecentTabs();
+ is(
+ sortedTabs[0],
+ privateWin.gBrowser.selectedTab,
+ "The most-recent private tab is unchanged"
+ );
+
+ sortedTabs = NonPrivateTabs.getRecentTabs();
+ is(
+ sortedTabs[0],
+ win1.gBrowser.selectedTab,
+ "The most-recent non-private tab is the selected tab in the current window"
+ );
+
+ await cleanup("TabRecencyChange");
+ while (win0.gBrowser.tabs.length > 1) {
+ info(
+ "Removing last tab:" +
+ win0.gBrowser.tabs.at(-1).linkedBrowser.currentURI.spec
+ );
+ BrowserTestUtils.removeTab(win0.gBrowser.tabs.at(-1));
+ info("Removed, tabs.length:" + win0.gBrowser.tabs.length);
+ }
+});
+
+add_task(async function test_tabNavigations() {
+ const { windows, cleanup } = await setup("TabChange");
+ const [, win1, privateWin] = windows;
+
+ // also listen for TabRecencyChange events
+ const nonPrivateRecencyListener = sinon.stub();
+ const privateRecencyListener = sinon.stub();
+ privateTabsChanges.addEventListener(
+ "TabRecencyChange",
+ privateRecencyListener
+ );
+ NonPrivateTabs.addEventListener(
+ "TabRecencyChange",
+ nonPrivateRecencyListener
+ );
+
+ info(
+ `Verify navigating in tab generates TabChange & TabRecencyChange events`
+ );
+ let loaded = BrowserTestUtils.browserLoaded(win1.gBrowser.selectedBrowser);
+ win1.gBrowser.selectedBrowser.loadURI(Services.io.newURI(tabURL4), {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+ info("waiting for the load into win1 tab to complete");
+ await loaded;
+ info("waiting for listeners to be called");
+ await BrowserTestUtils.waitForCondition(() => {
+ return nonPrivateListener.called && nonPrivateRecencyListener.called;
+ });
+ ok(!privateListener.called, "The private TabChange listener was not called");
+ ok(
+ !privateRecencyListener.called,
+ "The private TabRecencyChange listener was not called"
+ );
+
+ nonPrivateListener.resetHistory();
+ privateListener.resetHistory();
+ nonPrivateRecencyListener.resetHistory();
+ privateRecencyListener.resetHistory();
+
+ // Now verify the same with a private window
+ info(
+ `Verify navigating in private tab generates TabChange & TabRecencyChange events`
+ );
+ ok(
+ !nonPrivateListener.called,
+ "The non-private TabChange listener is not yet called"
+ );
+
+ loaded = BrowserTestUtils.browserLoaded(privateWin.gBrowser.selectedBrowser);
+ privateWin.gBrowser.selectedBrowser.loadURI(
+ Services.io.newURI("about:robots"),
+ {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ }
+ );
+ info("waiting for the load into privateWin tab to complete");
+ await loaded;
+ info("waiting for the privateListeners to be called");
+ await BrowserTestUtils.waitForCondition(() => {
+ return privateListener.called && privateRecencyListener.called;
+ });
+ ok(
+ !nonPrivateListener.called,
+ "The non-private TabChange listener was not called"
+ );
+ ok(
+ !nonPrivateRecencyListener.called,
+ "The non-private TabRecencyChange listener was not called"
+ );
+
+ // cleanup
+ privateTabsChanges.removeEventListener(
+ "TabRecencyChange",
+ privateRecencyListener
+ );
+ NonPrivateTabs.removeEventListener(
+ "TabRecencyChange",
+ nonPrivateRecencyListener
+ );
+
+ await cleanup();
+});
+
+add_task(async function test_tabsFromPrivateWindows() {
+ const { cleanup } = await setup("TabChange");
+ const private2Listener = sinon.stub();
+
+ const private2Win = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ waitForTabURL: "about:privatebrowsing",
+ });
+ const private2TabsChanges = getTabsTargetForWindow(private2Win);
+ private2TabsChanges.addEventListener("TabChange", private2Listener);
+ ok(
+ privateTabsChanges !== getTabsTargetForWindow(private2Win),
+ "getTabsTargetForWindow creates a distinct instance for a different private window"
+ );
+
+ await BrowserTestUtils.waitForCondition(() => private2Listener.called);
+
+ ok(
+ !privateListener.called,
+ "No TabChange event was raised by opening a different private window"
+ );
+ privateListener.resetHistory();
+ private2Listener.resetHistory();
+
+ BrowserTestUtils.addTab(private2Win.gBrowser, tabURL1);
+ await BrowserTestUtils.waitForCondition(() => private2Listener.called);
+ ok(
+ !privateListener.called,
+ "No TabChange event was raised by adding tab to a different private window"
+ );
+
+ is(
+ privateTabsChanges.getRecentTabs().length,
+ 1,
+ "The recent tab count for the first private window tab target only reports the tabs for its associated windodw"
+ );
+ is(
+ private2TabsChanges.getRecentTabs().length,
+ 2,
+ "The recent tab count for a 2nd private window tab target only reports the tabs for its associated windodw"
+ );
+
+ await cleanup("TabChange");
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_opentabs_firefoxview.js b/browser/components/firefoxview/tests/browser/browser_opentabs_firefoxview.js
new file mode 100644
index 0000000000..57d0f8d031
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_opentabs_firefoxview.js
@@ -0,0 +1,423 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const TEST_URL1 = "about:robots";
+const TEST_URL2 = "https://example.org/";
+const TEST_URL3 = "about:mozilla";
+
+const fxaDevicesWithCommands = [
+ {
+ id: 1,
+ name: "My desktop device",
+ availableCommands: { "https://identity.mozilla.com/cmd/open-uri": "test" },
+ lastAccessTime: Date.now(),
+ },
+ {
+ id: 2,
+ name: "My mobile device",
+ availableCommands: { "https://identity.mozilla.com/cmd/open-uri": "boo" },
+ lastAccessTime: Date.now() + 60000, // add 30min
+ },
+];
+
+const { NonPrivateTabs } = ChromeUtils.importESModule(
+ "resource:///modules/OpenTabs.sys.mjs"
+);
+
+async function getRowsForCard(card) {
+ await TestUtils.waitForCondition(() => card.tabList.rowEls.length);
+ return card.tabList.rowEls;
+}
+
+async function add_new_tab(URL) {
+ let tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabChange"
+ );
+ let tab = BrowserTestUtils.addTab(gBrowser, URL);
+ // wait so we can reliably compare the tab URL
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ await tabChangeRaised;
+ return tab;
+}
+
+function getVisibleTabURLs(win = window) {
+ return win.gBrowser.visibleTabs.map(tab => tab.linkedBrowser.currentURI.spec);
+}
+
+function getTabRowURLs(rows) {
+ return Array.from(rows).map(row => row.url);
+}
+
+async function waitUntilRowsMatch(openTabs, cardIndex, expectedURLs) {
+ let card;
+
+ info(
+ "moreMenuSetup: openTabs has openTabsTarget?:" + !!openTabs?.openTabsTarget
+ );
+ //await openTabs.openTabsTarget.readyWindowsPromise;
+ info(
+ `waitUntilRowsMatch, wait for there to be at least ${cardIndex + 1} cards`
+ );
+ await BrowserTestUtils.waitForCondition(() => {
+ if (!openTabs.initialWindowsReady) {
+ info("openTabs.initialWindowsReady isn't true");
+ return false;
+ }
+ try {
+ card = getOpenTabsCards(openTabs)[cardIndex];
+ } catch (ex) {
+ info("Calling getOpenTabsCards produced exception: " + ex.message);
+ }
+ return !!card;
+ }, "Waiting for openTabs to be ready and to get the cards");
+
+ const expectedURLsAsString = JSON.stringify(expectedURLs);
+ info(`Waiting for row URLs to match ${expectedURLs.join(", ")}`);
+ await BrowserTestUtils.waitForMutationCondition(
+ card.shadowRoot,
+ { characterData: true, childList: true, subtree: true },
+ async () => {
+ let rows = await getRowsForCard(card);
+ return (
+ rows.length == expectedURLs.length &&
+ JSON.stringify(getTabRowURLs(rows)) == expectedURLsAsString
+ );
+ }
+ );
+}
+
+async function getContextMenuPanelListForCard(card) {
+ let menuContainer = card.shadowRoot.querySelector(
+ "view-opentabs-contextmenu"
+ );
+ ok(menuContainer, "Found the menuContainer for card");
+ await TestUtils.waitForCondition(
+ () => menuContainer.panelList,
+ "Waiting for the context menu's panel-list to be rendered"
+ );
+ ok(
+ menuContainer.panelList,
+ "Found the panelList in the card's view-opentabs-contextmenu"
+ );
+ return menuContainer.panelList;
+}
+
+async function openContextMenuForItem(tabItem, card) {
+ // click on the item's button element (more menu)
+ // and wait for the panel list to be shown
+ tabItem.buttonEl.click();
+ // NOTE: menu must populate with devices data before it can be rendered
+ // so the creation of the panel-list can be async
+ let panelList = await getContextMenuPanelListForCard(card);
+ await BrowserTestUtils.waitForEvent(panelList, "shown");
+ return panelList;
+}
+
+async function moreMenuSetup() {
+ await add_new_tab(TEST_URL2);
+ await add_new_tab(TEST_URL3);
+
+ // once we've opened a few tabs, navigate to the open tabs section in firefox view
+ await clickFirefoxViewButton(window);
+ const document = window.FirefoxViewHandler.tab.linkedBrowser.contentDocument;
+
+ await navigateToCategoryAndWait(document, "opentabs");
+
+ let openTabs = document.querySelector("view-opentabs[name=opentabs]");
+ setSortOption(openTabs, "tabStripOrder");
+ await openTabs.openTabsTarget.readyWindowsPromise;
+
+ info("waiting for openTabs' first card rows");
+ await waitUntilRowsMatch(openTabs, 0, getVisibleTabURLs());
+
+ let cards = getOpenTabsCards(openTabs);
+ is(cards.length, 1, "There is one open window.");
+
+ let rows = await getRowsForCard(cards[0]);
+
+ let firstTab = rows[0];
+
+ firstTab.scrollIntoView();
+ is(
+ isElInViewport(firstTab),
+ true,
+ "first tab list item is visible in viewport"
+ );
+
+ return [cards, rows];
+}
+
+add_task(async function test_more_menus() {
+ await withFirefoxView({}, async browser => {
+ let win = browser.ownerGlobal;
+ let shown, menuHidden;
+
+ gBrowser.selectedTab = gBrowser.visibleTabs[0];
+ Assert.equal(
+ gBrowser.selectedTab.linkedBrowser.currentURI.spec,
+ "about:blank",
+ "Selected tab is about:blank"
+ );
+
+ info(`Loading ${TEST_URL1} into the selected about:blank tab`);
+ let tabLoaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+ win.gURLBar.focus();
+ win.gURLBar.value = TEST_URL1;
+ EventUtils.synthesizeKey("KEY_Enter", {}, win);
+ await tabLoaded;
+
+ info("Waiting for moreMenuSetup to resolve");
+ let [cards, rows] = await moreMenuSetup();
+ Assert.deepEqual(
+ getVisibleTabURLs(),
+ [TEST_URL1, TEST_URL2, TEST_URL3],
+ "Prepared 3 open tabs"
+ );
+
+ let firstTab = rows[0];
+ // Open the panel list (more menu) from the first list item
+ let panelList = await openContextMenuForItem(firstTab, cards[0]);
+
+ // Close Tab menu item
+ info("Panel list shown. Clicking on panel-item");
+ let panelItem = panelList.querySelector(
+ "panel-item[data-l10n-id=fxviewtabrow-close-tab]"
+ );
+ let panelItemButton = panelItem.shadowRoot.querySelector(
+ "button[role=menuitem]"
+ );
+ ok(panelItem, "Close Tab panel item exists");
+ ok(
+ panelItemButton,
+ "Close Tab panel item button with role=menuitem exists"
+ );
+
+ await clearAllParentTelemetryEvents();
+ let contextMenuEvent = [
+ [
+ "firefoxview_next",
+ "context_menu",
+ "tabs",
+ undefined,
+ { menu_action: "close-tab", data_type: "opentabs" },
+ ],
+ ];
+
+ // close a tab via the menu
+ let tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabChange"
+ );
+ menuHidden = BrowserTestUtils.waitForEvent(panelList, "hidden");
+ panelItemButton.click();
+ info("Waiting for result of closing a tab via the menu");
+ await tabChangeRaised;
+ await cards[0].getUpdateComplete();
+ await menuHidden;
+ await telemetryEvent(contextMenuEvent);
+
+ Assert.deepEqual(
+ getVisibleTabURLs(),
+ [TEST_URL2, TEST_URL3],
+ "Got the expected 2 open tabs"
+ );
+
+ let openTabs = cards[0].ownerDocument.querySelector(
+ "view-opentabs[name=opentabs]"
+ );
+ await waitUntilRowsMatch(openTabs, 0, [TEST_URL2, TEST_URL3]);
+
+ // Move Tab submenu item
+ firstTab = rows[0];
+ is(firstTab.url, TEST_URL2, `First tab list item is ${TEST_URL2}`);
+
+ panelList = await openContextMenuForItem(firstTab, cards[0]);
+ let moveTabsPanelItem = panelList.querySelector(
+ "panel-item[data-l10n-id=fxviewtabrow-move-tab]"
+ );
+
+ let moveTabsSubmenuList = moveTabsPanelItem.shadowRoot.querySelector(
+ "panel-list[id=move-tab-menu]"
+ );
+ ok(moveTabsSubmenuList, "Move tabs submenu panel list exists");
+
+ // navigate down to the "Move tabs" submenu option, and
+ // open it with the right arrow key
+ EventUtils.synthesizeKey("KEY_ArrowDown", {});
+ shown = BrowserTestUtils.waitForEvent(moveTabsSubmenuList, "shown");
+ EventUtils.synthesizeKey("KEY_ArrowRight", {});
+ await shown;
+
+ await clearAllParentTelemetryEvents();
+ contextMenuEvent = [
+ [
+ "firefoxview_next",
+ "context_menu",
+ "tabs",
+ null,
+ { menu_action: "move-tab-end", data_type: "opentabs" },
+ ],
+ ];
+
+ // click on the first option, which should be "Move to the end" since
+ // this is the first tab
+ menuHidden = BrowserTestUtils.waitForEvent(panelList, "hidden");
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabChange"
+ );
+ EventUtils.synthesizeKey("KEY_Enter", {});
+ info("Waiting for result of moving a tab via the menu");
+ await telemetryEvent(contextMenuEvent);
+ await menuHidden;
+ await tabChangeRaised;
+
+ Assert.deepEqual(
+ getVisibleTabURLs(),
+ [TEST_URL3, TEST_URL2],
+ "The last tab became the first tab"
+ );
+
+ // this entire "move tabs" submenu test can be reordered above
+ // closing a tab since it very clearly reveals the issues
+ // outlined in bug 1852622 when there are 3 or more tabs open
+ // and one is moved via the more menus.
+ await waitUntilRowsMatch(openTabs, 0, [TEST_URL3, TEST_URL2]);
+
+ // Copy Link menu item (copyLink function that's called is a member of Viewpage.mjs)
+ panelList = await openContextMenuForItem(firstTab, cards[0]);
+ firstTab = rows[0];
+ panelItem = panelList.querySelector(
+ "panel-item[data-l10n-id=fxviewtabrow-copy-link]"
+ );
+ panelItemButton = panelItem.shadowRoot.querySelector(
+ "button[role=menuitem]"
+ );
+ ok(panelItem, "Copy link panel item exists");
+ ok(
+ panelItemButton,
+ "Copy link panel item button with role=menuitem exists"
+ );
+
+ await clearAllParentTelemetryEvents();
+ contextMenuEvent = [
+ [
+ "firefoxview_next",
+ "context_menu",
+ "tabs",
+ null,
+ { menu_action: "copy-link", data_type: "opentabs" },
+ ],
+ ];
+
+ menuHidden = BrowserTestUtils.waitForEvent(panelList, "hidden");
+ panelItemButton.click();
+ info("Waiting for menuHidden");
+ await menuHidden;
+ info("Waiting for telemetryEvent");
+ await telemetryEvent(contextMenuEvent);
+
+ let copiedText = SpecialPowers.getClipboardData(
+ "text/plain",
+ Ci.nsIClipboard.kGlobalClipboard
+ );
+ is(copiedText, TEST_URL3, "The correct url has been copied and pasted");
+
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.tabs[0]);
+ }
+ });
+});
+
+add_task(async function test_send_device_submenu() {
+ const sandbox = setupMocks({
+ state: UIState.STATUS_SIGNED_IN,
+ fxaDevices: [
+ {
+ id: 1,
+ name: "This Device",
+ isCurrentDevice: true,
+ type: "desktop",
+ tabs: [],
+ },
+ ],
+ });
+ sandbox
+ .stub(gSync, "getSendTabTargets")
+ .callsFake(() => fxaDevicesWithCommands);
+
+ await withFirefoxView({}, async browser => {
+ // TEST_URL2 is our only tab, left over from previous test
+ Assert.deepEqual(
+ getVisibleTabURLs(),
+ [TEST_URL2],
+ `We initially have a single ${TEST_URL2} tab`
+ );
+ let shown;
+
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+ let [cards, rows] = await moreMenuSetup(document);
+
+ let firstTab = rows[0];
+ let panelList = await openContextMenuForItem(firstTab, cards[0]);
+
+ let sendTabPanelItem = panelList.querySelector(
+ "panel-item[data-l10n-id=fxviewtabrow-send-tab]"
+ );
+
+ ok(sendTabPanelItem, "Send tabs to device submenu panel item exists");
+
+ let sendTabSubmenuList = sendTabPanelItem.shadowRoot.querySelector(
+ "panel-list[id=send-tab-menu]"
+ );
+ ok(sendTabSubmenuList, "Send tabs to device submenu panel list exists");
+
+ // navigate down to the "Send tabs" submenu option, and
+ // open it with the right arrow key
+ EventUtils.synthesizeKey("KEY_ArrowDown", {});
+ EventUtils.synthesizeKey("KEY_ArrowDown", {});
+ EventUtils.synthesizeKey("KEY_ArrowDown", {});
+
+ shown = BrowserTestUtils.waitForEvent(sendTabSubmenuList, "shown");
+ EventUtils.synthesizeKey("KEY_ArrowRight", {});
+ await shown;
+
+ let expectation = sandbox
+ .mock(gSync)
+ .expects("sendTabToDevice")
+ .once()
+ .withExactArgs(
+ TEST_URL2,
+ [fxaDevicesWithCommands[0]],
+ "mochitest index /"
+ )
+ .returns(true);
+
+ await clearAllParentTelemetryEvents();
+ let contextMenuEvent = [
+ [
+ "firefoxview_next",
+ "context_menu",
+ "tabs",
+ null,
+ { menu_action: "send-tab-device", data_type: "opentabs" },
+ ],
+ ];
+
+ // click on the first device and verify it was "sent"
+ let menuHidden = BrowserTestUtils.waitForEvent(panelList, "hidden");
+ EventUtils.synthesizeKey("KEY_Enter", {});
+
+ expectation.verify();
+ await telemetryEvent(contextMenuEvent);
+ await menuHidden;
+
+ sandbox.restore();
+ TabsSetupFlowManager.resetInternalState();
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.tabs[0]);
+ }
+ });
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_opentabs_recency.js b/browser/components/firefoxview/tests/browser/browser_opentabs_recency.js
new file mode 100644
index 0000000000..e5beb4700a
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_opentabs_recency.js
@@ -0,0 +1,408 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ This test checks the recent-browsing view of open tabs in about:firefoxview next
+ presents the correct tab data in the correct order.
+*/
+
+const tabURL1 = "data:,Tab1";
+const tabURL2 = "data:,Tab2";
+const tabURL3 = "data:,Tab3";
+const tabURL4 = "data:,Tab4";
+
+let gInitialTab;
+let gInitialTabURL;
+const { NonPrivateTabs } = ChromeUtils.importESModule(
+ "resource:///modules/OpenTabs.sys.mjs"
+);
+
+add_setup(function () {
+ gInitialTab = gBrowser.selectedTab;
+ gInitialTabURL = tabUrl(gInitialTab);
+});
+
+function tabUrl(tab) {
+ return tab.linkedBrowser.currentURI?.spec;
+}
+
+async function minimizeWindow(win) {
+ let promiseSizeModeChange = BrowserTestUtils.waitForEvent(
+ win,
+ "sizemodechange"
+ );
+ win.minimize();
+ await promiseSizeModeChange;
+ ok(
+ !win.gBrowser.selectedTab.linkedBrowser.docShellIsActive,
+ "Docshell should be Inactive"
+ );
+ ok(win.document.hidden, "Top level window should be hidden");
+}
+
+async function restoreWindow(win) {
+ ok(win.document.hidden, "Top level window should be hidden");
+ let promiseSizeModeChange = BrowserTestUtils.waitForEvent(
+ win,
+ "sizemodechange"
+ );
+
+ // Check if we also need to wait for occlusion to be updated.
+ let promiseOcclusion;
+ let willWaitForOcclusion = win.isFullyOccluded;
+ if (willWaitForOcclusion) {
+ // Not only do we need to wait for the occlusionstatechange event,
+ // we also have to wait *one more event loop* to ensure that the
+ // other listeners to the occlusionstatechange events have fired.
+ // Otherwise, our browsing context might not have become active
+ // at the point where we receive the occlusionstatechange event.
+ promiseOcclusion = BrowserTestUtils.waitForEvent(
+ win,
+ "occlusionstatechange"
+ ).then(() => new Promise(resolve => SimpleTest.executeSoon(resolve)));
+ } else {
+ promiseOcclusion = Promise.resolve();
+ }
+
+ info("Calling window.restore");
+ win.restore();
+ // From browser/base/content/test/general/browser_minimize.js:
+ // On Ubuntu `window.restore` doesn't seem to work, use a timer to make the
+ // test fail faster and more cleanly than with a test timeout.
+ info(
+ `Waiting for sizemodechange ${
+ willWaitForOcclusion ? "and occlusionstatechange " : ""
+ }event`
+ );
+ let timer;
+ await Promise.race([
+ Promise.all([promiseSizeModeChange, promiseOcclusion]),
+ new Promise((resolve, reject) => {
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ timer = setTimeout(() => {
+ reject(
+ `timed out waiting for sizemodechange sizemodechange ${
+ willWaitForOcclusion ? "and occlusionstatechange " : ""
+ }event`
+ );
+ }, 5000);
+ }),
+ ]);
+ clearTimeout(timer);
+ ok(
+ win.gBrowser.selectedTab.linkedBrowser.docShellIsActive,
+ "Docshell should be active again"
+ );
+ ok(!win.document.hidden, "Top level window should be visible");
+}
+
+async function prepareOpenTabs(urls, win = window) {
+ const reusableTabURLs = ["about:newtab", "about:blank"];
+ const gBrowser = win.gBrowser;
+
+ for (let url of urls) {
+ if (
+ gBrowser.visibleTabs.length == 1 &&
+ reusableTabURLs.includes(gBrowser.selectedBrowser.currentURI.spec)
+ ) {
+ // we'll load into this tab rather than opening a new one
+ info(
+ `Loading ${url} into blank tab: ${gBrowser.selectedBrowser.currentURI.spec}`
+ );
+ BrowserTestUtils.startLoadingURIString(gBrowser.selectedBrowser, url);
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser, null, url);
+ } else {
+ info(`Loading ${url} into new tab`);
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ }
+ await new Promise(res => win.requestAnimationFrame(res));
+ }
+ Assert.equal(
+ gBrowser.visibleTabs.length,
+ urls.length,
+ `Prepared ${urls.length} tabs as expected`
+ );
+ Assert.equal(
+ tabUrl(gBrowser.selectedTab),
+ urls[urls.length - 1],
+ "The selectedTab is the last of the URLs given as expected"
+ );
+}
+
+async function cleanup(...windowsToClose) {
+ await Promise.all(
+ windowsToClose.map(win => BrowserTestUtils.closeWindow(win))
+ );
+
+ while (gBrowser.visibleTabs.length > 1) {
+ await SessionStoreTestUtils.closeTab(gBrowser.tabs.at(-1));
+ }
+ if (gBrowser.selectedBrowser.currentURI.spec !== gInitialTabURL) {
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser.selectedBrowser,
+ gInitialTabURL
+ );
+ await BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ null,
+ gInitialTabURL
+ );
+ }
+}
+
+function getOpenTabsComponent(browser) {
+ return browser.contentDocument.querySelector(
+ "view-recentbrowsing view-opentabs"
+ );
+}
+
+async function checkTabList(browser, expected) {
+ const tabsView = getOpenTabsComponent(browser);
+ const openTabsCard = tabsView.shadowRoot.querySelector("view-opentabs-card");
+ await tabsView.getUpdateComplete();
+ const tabList = openTabsCard.shadowRoot.querySelector("fxview-tab-list");
+ Assert.ok(tabList, "Found the tab list element");
+ await TestUtils.waitForCondition(() => tabList.rowEls.length);
+ let actual = Array.from(tabList.rowEls).map(row => row.url);
+ Assert.deepEqual(
+ actual,
+ expected,
+ "Tab list has items with URLs in the expected order"
+ );
+}
+
+add_task(async function test_single_window_tabs() {
+ await prepareOpenTabs([tabURL1, tabURL2]);
+ await openFirefoxViewTab(window).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ await checkTabList(browser, [tabURL2, tabURL1]);
+
+ // switch to the first tab
+ let promiseHidden = BrowserTestUtils.waitForEvent(
+ browser.contentDocument,
+ "visibilitychange"
+ );
+
+ let tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabRecencyChange"
+ );
+ await BrowserTestUtils.switchTab(gBrowser, gBrowser.visibleTabs[0]);
+ await promiseHidden;
+ await tabChangeRaised;
+ });
+
+ // and check the results in the open tabs section of Recent Browsing
+ await openFirefoxViewTab(window).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ await checkTabList(browser, [tabURL1, tabURL2]);
+ });
+ await cleanup();
+});
+
+add_task(async function test_multiple_window_tabs() {
+ const fxViewURL = getFirefoxViewURL();
+ const win1 = window;
+ let tabChangeRaised;
+ await prepareOpenTabs([tabURL1, tabURL2]);
+ const win2 = await BrowserTestUtils.openNewBrowserWindow();
+ await prepareOpenTabs([tabURL3, tabURL4], win2);
+
+ // to avoid confusing the results by activating different windows,
+ // check fxview in the current window - which is win2
+ info("Switching to fxview tab in win2");
+ await openFirefoxViewTab(win2).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ await checkTabList(browser, [tabURL4, tabURL3, tabURL2, tabURL1]);
+
+ Assert.equal(
+ tabUrl(win2.gBrowser.selectedTab),
+ fxViewURL,
+ `The selected tab in window 2 is ${fxViewURL}`
+ );
+
+ info("Switching to first tab (tab3) in win2");
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabRecencyChange"
+ );
+ let promiseHidden = BrowserTestUtils.waitForEvent(
+ browser.contentDocument,
+ "visibilitychange"
+ );
+ await BrowserTestUtils.switchTab(
+ win2.gBrowser,
+ win2.gBrowser.visibleTabs[0]
+ );
+ Assert.equal(
+ tabUrl(win2.gBrowser.selectedTab),
+ tabURL3,
+ `The selected tab in window 2 is ${tabURL3}`
+ );
+ await tabChangeRaised;
+ await promiseHidden;
+ });
+
+ info("Opening fxview in win2 to confirm tab3 is most recent");
+ await openFirefoxViewTab(win2).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ info("Check result of selecting 1ist tab in window 2");
+ await checkTabList(browser, [tabURL3, tabURL4, tabURL2, tabURL1]);
+ });
+
+ info("Focusing win1, where tab2 should be selected");
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabRecencyChange"
+ );
+ await SimpleTest.promiseFocus(win1);
+ await tabChangeRaised;
+ Assert.equal(
+ tabUrl(win1.gBrowser.selectedTab),
+ tabURL2,
+ `The selected tab in window 1 is ${tabURL2}`
+ );
+
+ info("Opening fxview in win1 to confirm tab2 is most recent");
+ await openFirefoxViewTab(win1).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ info(
+ "In fxview, check result of activating window 1, where tab 2 is selected"
+ );
+ await checkTabList(browser, [tabURL2, tabURL3, tabURL4, tabURL1]);
+
+ let promiseHidden = BrowserTestUtils.waitForEvent(
+ browser.contentDocument,
+ "visibilitychange"
+ );
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabRecencyChange"
+ );
+ info("Switching to first visible tab (tab1) in win1");
+ await BrowserTestUtils.switchTab(
+ win1.gBrowser,
+ win1.gBrowser.visibleTabs[0]
+ );
+ await promiseHidden;
+ await tabChangeRaised;
+ });
+
+ // check result in the fxview in the 1st window
+ info("Opening fxview in win1 to confirm tab1 is most recent");
+ await openFirefoxViewTab(win1).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ info("Check result of selecting 1st tab in win1");
+ await checkTabList(browser, [tabURL1, tabURL2, tabURL3, tabURL4]);
+ });
+
+ await cleanup(win2);
+});
+
+add_task(async function test_windows_activation() {
+ const win1 = window;
+ await prepareOpenTabs([tabURL1], win1);
+ let fxViewTab;
+ let tabChangeRaised;
+ info("switch to firefox-view and leave it selected");
+ await openFirefoxViewTab(win1).then(tab => (fxViewTab = tab));
+
+ const win2 = await BrowserTestUtils.openNewBrowserWindow();
+ await prepareOpenTabs([tabURL2], win2);
+
+ const win3 = await BrowserTestUtils.openNewBrowserWindow();
+ await prepareOpenTabs([tabURL3], win3);
+ await tabChangeRaised;
+
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabRecencyChange"
+ );
+ await SimpleTest.promiseFocus(win1);
+ await tabChangeRaised;
+
+ const browser = fxViewTab.linkedBrowser;
+ await checkTabList(browser, [tabURL3, tabURL2, tabURL1]);
+
+ info("switch to win2 and confirm its selected tab becomes most recent");
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabRecencyChange"
+ );
+ await SimpleTest.promiseFocus(win2);
+ await tabChangeRaised;
+ await checkTabList(browser, [tabURL2, tabURL3, tabURL1]);
+ await cleanup(win2, win3);
+});
+
+add_task(async function test_minimize_restore_windows() {
+ const win1 = window;
+ let tabChangeRaised;
+ await prepareOpenTabs([tabURL1, tabURL2]);
+ const win2 = await BrowserTestUtils.openNewBrowserWindow();
+ await prepareOpenTabs([tabURL3, tabURL4], win2);
+
+ // to avoid confusing the results by activating different windows,
+ // check fxview in the current window - which is win2
+ info("Opening fxview in win2 to confirm tab4 is most recent");
+ await openFirefoxViewTab(win2).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ await checkTabList(browser, [tabURL4, tabURL3, tabURL2, tabURL1]);
+
+ let promiseHidden = BrowserTestUtils.waitForEvent(
+ browser.contentDocument,
+ "visibilitychange"
+ );
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabRecencyChange"
+ );
+ info("Switching to the first tab (tab3) in 2nd window");
+ await BrowserTestUtils.switchTab(
+ win2.gBrowser,
+ win2.gBrowser.visibleTabs[0]
+ );
+ await promiseHidden;
+ await tabChangeRaised;
+ });
+
+ // then minimize the window, focusing the 1st window
+ info("Minimizing win2, leaving tab 3 selected");
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabRecencyChange"
+ );
+ await minimizeWindow(win2);
+ info("Focusing win1, where tab2 is selected - making it most recent");
+ await SimpleTest.promiseFocus(win1);
+ await tabChangeRaised;
+
+ Assert.equal(
+ tabUrl(win1.gBrowser.selectedTab),
+ tabURL2,
+ `The selected tab in window 1 is ${tabURL2}`
+ );
+
+ info("Opening fxview in win1 to confirm tab2 is most recent");
+ await openFirefoxViewTab(win1).then(async viewTab => {
+ const browser = viewTab.linkedBrowser;
+ await checkTabList(browser, [tabURL2, tabURL3, tabURL4, tabURL1]);
+ info(
+ "Restoring win2 and focusing it - which should make its selected tab most recent"
+ );
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabRecencyChange"
+ );
+ await restoreWindow(win2);
+ await SimpleTest.promiseFocus(win2);
+ await tabChangeRaised;
+
+ info(
+ "Checking tab order in fxview in win1, to confirm tab3 is most recent"
+ );
+ await checkTabList(browser, [tabURL3, tabURL2, tabURL4, tabURL1]);
+ });
+
+ await cleanup(win2);
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_opentabs_tab_indicators.js b/browser/components/firefoxview/tests/browser/browser_opentabs_tab_indicators.js
new file mode 100644
index 0000000000..1375052125
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_opentabs_tab_indicators.js
@@ -0,0 +1,207 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { NonPrivateTabs } = ChromeUtils.importESModule(
+ "resource:///modules/OpenTabs.sys.mjs"
+);
+
+let pageWithAlert =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/browser/browser/base/content/test/tabPrompts/openPromptOffTimeout.html";
+let pageWithSound =
+ "http://mochi.test:8888/browser/dom/base/test/file_audioLoop.html";
+
+function cleanup() {
+ // Cleanup
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.tabs[0]);
+ }
+}
+
+add_task(async function test_notification_dot_indicator() {
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ let win = browser.ownerGlobal;
+ await navigateToCategoryAndWait(document, "opentabs");
+ // load page that opens prompt when page is hidden
+ let openedTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ pageWithAlert,
+ true
+ );
+ let openedTabGotAttentionPromise = BrowserTestUtils.waitForAttribute(
+ "attention",
+ openedTab
+ );
+
+ let tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabChange"
+ );
+
+ await switchToFxViewTab();
+
+ let openTabs = document.querySelector("view-opentabs[name=opentabs]");
+
+ await openedTabGotAttentionPromise;
+ await tabChangeRaised;
+ await openTabs.updateComplete;
+
+ await TestUtils.waitForCondition(
+ () => openTabs.viewCards[0].tabList.rowEls[1].attention,
+ "The opened tab doesn't have the attention property, so no notification dot is shown."
+ );
+
+ info("The newly opened tab has a notification dot.");
+
+ // Switch back to other tab to close prompt before cleanup
+ await BrowserTestUtils.switchTab(gBrowser, openedTab);
+ EventUtils.synthesizeKey("KEY_Enter", {}, win);
+
+ cleanup();
+ });
+});
+
+add_task(async function test_container_indicator() {
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ let win = browser.ownerGlobal;
+
+ // Load a page in a container tab
+ let userContextId = 1;
+ let containerTab = BrowserTestUtils.addTab(win.gBrowser, URLs[0], {
+ userContextId,
+ });
+
+ await BrowserTestUtils.browserLoaded(
+ containerTab.linkedBrowser,
+ false,
+ URLs[0]
+ );
+
+ await navigateToCategoryAndWait(document, "opentabs");
+
+ let openTabs = document.querySelector("view-opentabs[name=opentabs]");
+
+ await TestUtils.waitForCondition(
+ () =>
+ containerTab.getAttribute("usercontextid") === userContextId.toString(),
+ "The container tab doesn't have the usercontextid attribute."
+ );
+ await openTabs.updateComplete;
+ await TestUtils.waitForCondition(
+ () => openTabs.viewCards[0].tabList?.rowEls.length,
+ "The tab list hasn't rendered."
+ );
+ info("openTabs component has finished updating.");
+
+ let containerTabElem = openTabs.viewCards[0].tabList.rowEls[1];
+
+ await TestUtils.waitForCondition(
+ () => containerTabElem.containerObj,
+ "The container tab element isn't marked in Fx View."
+ );
+
+ ok(
+ containerTabElem.shadowRoot
+ .querySelector(".fxview-tab-row-container-indicator")
+ .classList.contains("identity-color-blue"),
+ "The container color is blue."
+ );
+
+ info("The newly opened tab is marked as a container tab.");
+
+ cleanup();
+ });
+});
+
+add_task(async function test_sound_playing_muted_indicator() {
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "opentabs");
+
+ // Load a page in a container tab
+ let soundTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ pageWithSound,
+ true
+ );
+
+ let tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabChange"
+ );
+
+ await switchToFxViewTab();
+
+ let openTabs = document.querySelector("view-opentabs[name=opentabs]");
+
+ await TestUtils.waitForCondition(() =>
+ soundTab.hasAttribute("soundplaying")
+ );
+ await tabChangeRaised;
+ await openTabs.updateComplete;
+ await TestUtils.waitForCondition(
+ () => openTabs.viewCards[0].tabList?.rowEls.length,
+ "The tab list hasn't rendered."
+ );
+
+ let soundPlayingTabElem = openTabs.viewCards[0].tabList.rowEls[1];
+
+ await TestUtils.waitForCondition(() => soundPlayingTabElem.soundPlaying);
+
+ ok(
+ soundPlayingTabElem.mediaButtonEl,
+ "The tab has the mute button showing."
+ );
+
+ tabChangeRaised = BrowserTestUtils.waitForEvent(
+ NonPrivateTabs,
+ "TabChange"
+ );
+
+ // Mute the tab
+ EventUtils.synthesizeMouseAtCenter(
+ soundPlayingTabElem.mediaButtonEl,
+ {},
+ content
+ );
+
+ await TestUtils.waitForCondition(
+ () => soundTab.hasAttribute("muted"),
+ "The tab doesn't have the muted attribute."
+ );
+ await tabChangeRaised;
+ await openTabs.updateComplete;
+
+ await TestUtils.waitForCondition(() => soundPlayingTabElem.muted);
+
+ ok(
+ soundPlayingTabElem.mediaButtonEl,
+ "The tab has the unmute button showing."
+ );
+
+ // Mute and unmute the tab and make sure the element in Fx View updates
+ soundTab.toggleMuteAudio();
+ await tabChangeRaised;
+ await openTabs.updateComplete;
+ await TestUtils.waitForCondition(() => soundPlayingTabElem.soundPlaying);
+
+ ok(
+ soundPlayingTabElem.mediaButtonEl,
+ "The tab has the mute button showing."
+ );
+
+ soundTab.toggleMuteAudio();
+ await tabChangeRaised;
+ await openTabs.updateComplete;
+ await TestUtils.waitForCondition(() => soundPlayingTabElem.muted);
+
+ ok(
+ soundPlayingTabElem.mediaButtonEl,
+ "The tab has the unmute button showing."
+ );
+
+ cleanup();
+ });
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_recentlyclosed_firefoxview.js b/browser/components/firefoxview/tests/browser/browser_recentlyclosed_firefoxview.js
new file mode 100644
index 0000000000..313d86416e
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_recentlyclosed_firefoxview.js
@@ -0,0 +1,600 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+requestLongerTimeout(2);
+
+ChromeUtils.defineESModuleGetters(globalThis, {
+ SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs",
+});
+
+const NEVER_REMEMBER_HISTORY_PREF = "browser.privatebrowsing.autostart";
+const SEARCH_ENABLED_PREF = "browser.firefox-view.search.enabled";
+const RECENTLY_CLOSED_EVENT = [
+ ["firefoxview_next", "recently_closed", "tabs", undefined],
+];
+const DISMISS_CLOSED_TAB_EVENT = [
+ ["firefoxview_next", "dismiss_closed_tab", "tabs", undefined],
+];
+const initialTab = gBrowser.selectedTab;
+
+async function restore_tab(itemElem, browser, expectedURL) {
+ info(`Restoring tab ${itemElem.url}`);
+ let tabRestored = BrowserTestUtils.waitForNewTab(
+ browser.getTabBrowser(),
+ expectedURL
+ );
+ await click_recently_closed_tab_item(itemElem, "main");
+ await tabRestored;
+}
+
+async function dismiss_tab(itemElem) {
+ info(`Dismissing tab ${itemElem.url}`);
+ return click_recently_closed_tab_item(itemElem, "dismiss");
+}
+
+async function tabTestCleanup() {
+ await promiseAllButPrimaryWindowClosed();
+ for (let tab of gBrowser.visibleTabs) {
+ if (tab == initialTab) {
+ continue;
+ }
+ await TabStateFlusher.flush(tab.linkedBrowser);
+ let sessionUpdatePromise = BrowserTestUtils.waitForSessionStoreUpdate(tab);
+ BrowserTestUtils.removeTab(tab);
+ await sessionUpdatePromise;
+ }
+ Services.obs.notifyObservers(null, "browser:purge-session-history");
+}
+
+async function prepareSingleClosedTab() {
+ Services.obs.notifyObservers(null, "browser:purge-session-history");
+ is(
+ SessionStore.getClosedTabCount(window),
+ 0,
+ "Closed tab count after purging session history"
+ );
+ await open_then_close(URLs[0]);
+ return {
+ cleanup: tabTestCleanup,
+ };
+}
+
+async function prepareClosedTabs() {
+ Services.obs.notifyObservers(null, "browser:purge-session-history");
+ is(
+ SessionStore.getClosedTabCount(window),
+ 0,
+ "Closed tab count after purging session history"
+ );
+ is(
+ SessionStore.getClosedTabCountFromClosedWindows(),
+ 0,
+ "Closed tab count after purging session history"
+ );
+
+ await open_then_close(URLs[0]);
+ await open_then_close(URLs[1]);
+
+ // create 1 recently-closed tabs in a 2nd window
+ info("Opening win2 and open/closing tabs in it");
+ const win2 = await BrowserTestUtils.openNewBrowserWindow();
+ // open a non-transitory, worth-keeping tab to ensure window data is saved on close
+ await BrowserTestUtils.openNewForegroundTab(win2.gBrowser, "about:mozilla");
+ await open_then_close(URLs[2], win2);
+
+ info("Opening win3 and open/closing a tab in it");
+ const win3 = await BrowserTestUtils.openNewBrowserWindow();
+ // open a non-transitory, worth-keeping tab to ensure window data is saved on close
+ await BrowserTestUtils.openNewForegroundTab(win3.gBrowser, "about:mozilla");
+ await open_then_close(URLs[3], win3);
+
+ // close the 3rd window with its 1 recently-closed tab
+ info("closing win3 and waiting for sessionstore-closed-objects-changed");
+ await BrowserTestUtils.closeWindow(win3);
+
+ // refocus and bring the initial window to the foreground
+ await SimpleTest.promiseFocus(window);
+
+ // this is the order we expect for all the recently-closed tabs
+ const expectedURLs = [
+ "https://example.org/", // URLS[3]
+ "https://example.net/", // URLS[2]
+ "https://www.example.com/", // URLS[1]
+ "http://mochi.test:8888/browser/", // URLS[0]
+ ];
+ const preparedClosedTabCount = expectedURLs.length;
+
+ const closedTabsFromClosedWindowsCount =
+ SessionStore.getClosedTabCountFromClosedWindows();
+ is(
+ closedTabsFromClosedWindowsCount,
+ 1,
+ "Expected 1 closed tab from a closed window"
+ );
+
+ const closedTabsFromOpenWindowsCount = SessionStore.getClosedTabCount({
+ sourceWindow: window,
+ closedTabsFromClosedWindows: false,
+ });
+ const actualClosedTabCount = SessionStore.getClosedTabCount();
+ is(
+ closedTabsFromOpenWindowsCount,
+ 3,
+ "Expected 3 closed tabs currently-open windows"
+ );
+
+ is(
+ actualClosedTabCount,
+ preparedClosedTabCount,
+ `SessionStore reported the expected number (${actualClosedTabCount}) of closed tabs`
+ );
+
+ return {
+ cleanup: tabTestCleanup,
+ // return a list of the tab urls we closed in the order we closed them
+ closedTabURLs: [...URLs.slice(0, 4)],
+ expectedURLs,
+ };
+}
+
+async function recentlyClosedTelemetry() {
+ await TestUtils.waitForCondition(
+ () => {
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ false
+ ).parent;
+ return events && events.length >= 1;
+ },
+ "Waiting for recently_closed firefoxview telemetry event.",
+ 200,
+ 100
+ );
+
+ TelemetryTestUtils.assertEvents(
+ RECENTLY_CLOSED_EVENT,
+ { category: "firefoxview_next" },
+ { clear: true, process: "parent" }
+ );
+}
+
+async function recentlyClosedDismissTelemetry() {
+ 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(
+ DISMISS_CLOSED_TAB_EVENT,
+ { category: "firefoxview_next" },
+ { clear: true, process: "parent" }
+ );
+}
+
+add_setup(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [[SEARCH_ENABLED_PREF, true]],
+ });
+ registerCleanupFunction(async () => {
+ await SpecialPowers.popPrefEnv();
+ clearHistory();
+ });
+});
+
+/**
+ * Asserts that we get the expected initial recently-closed tab list item
+ */
+add_task(async function test_initial_closed_tab() {
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ is(document.location.href, getFirefoxViewURL());
+ await navigateToCategoryAndWait(document, "recentlyclosed");
+ let { cleanup } = await prepareSingleClosedTab();
+ await switchToFxViewTab(window);
+ let [listItems] = await waitForRecentlyClosedTabsList(document);
+
+ Assert.strictEqual(
+ listItems.rowEls.length,
+ 1,
+ "Initial list item is rendered."
+ );
+
+ await cleanup();
+ });
+});
+
+/**
+ * Asserts that we get the expected order recently-closed tab list items given a known
+ * sequence of tab closures
+ */
+add_task(async function test_list_ordering() {
+ let { cleanup, expectedURLs } = await prepareClosedTabs();
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ await clearAllParentTelemetryEvents();
+ navigateToCategory(document, "recentlyclosed");
+ let [cardMainSlotNode, listItems] = await waitForRecentlyClosedTabsList(
+ document
+ );
+
+ is(
+ cardMainSlotNode.tagName.toLowerCase(),
+ "fxview-tab-list",
+ "The tab list component is rendered."
+ );
+
+ Assert.deepEqual(
+ Array.from(listItems).map(el => el.url),
+ expectedURLs,
+ "The initial list has rendered the expected tab items in the right order"
+ );
+ });
+ await cleanup();
+});
+
+/**
+ * Asserts that an out-of-band update to recently-closed tabs results in the correct update to the tab list
+ */
+add_task(async function test_list_updates() {
+ let { cleanup, expectedURLs } = await prepareClosedTabs();
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ navigateToCategory(document, "recentlyclosed");
+
+ let [listElem, listItems] = await waitForRecentlyClosedTabsList(document);
+ Assert.deepEqual(
+ Array.from(listItems).map(el => el.url),
+ expectedURLs,
+ "The initial list has rendered the expected tab items in the right order"
+ );
+
+ // the first tab we opened and closed is the last in the list
+ let closedTabItem = listItems[listItems.length - 1];
+ is(
+ closedTabItem.url,
+ "http://mochi.test:8888/browser/",
+ "Sanity-check the least-recently closed tab is https://example.org/"
+ );
+ info(
+ `Restore the last (least-recently) closed tab ${closedTabItem.url}, closedId: ${closedTabItem.closedId} and wait for sessionstore-closed-objects-changed`
+ );
+ let promiseClosedObjectsChanged = TestUtils.topicObserved(
+ "sessionstore-closed-objects-changed"
+ );
+ SessionStore.undoCloseById(closedTabItem.closedId);
+ await promiseClosedObjectsChanged;
+ await clickFirefoxViewButton(window);
+
+ // we expect the last item to be removed
+ expectedURLs.pop();
+ listItems = listElem.rowEls;
+
+ is(
+ listItems.length,
+ 3,
+ `Three tabs are shown in the list: ${Array.from(listItems).map(
+ el => el.url
+ )}, of ${expectedURLs.length} expectedURLs: ${expectedURLs}`
+ );
+ Assert.deepEqual(
+ Array.from(listItems).map(el => el.url),
+ expectedURLs,
+ "The updated list has rendered the expected tab items in the right order"
+ );
+
+ // forget the window the most-recently closed tab was in and verify the list is correctly updated
+ closedTabItem = listItems[0];
+ promiseClosedObjectsChanged = TestUtils.topicObserved(
+ "sessionstore-closed-objects-changed"
+ );
+ SessionStore.forgetClosedWindowById(closedTabItem.sourceClosedId);
+ await promiseClosedObjectsChanged;
+ await clickFirefoxViewButton(window);
+
+ listItems = listElem.rowEls;
+ expectedURLs.shift(); // we expect to have removed the firsts URL from the list
+ is(listItems.length, 2, "Two tabs are shown in the list.");
+ Assert.deepEqual(
+ Array.from(listItems).map(el => el.url),
+ expectedURLs,
+ "After forgetting the closed window that owned the last recent tab, we have expected tab items in the right order"
+ );
+ });
+ await cleanup();
+});
+
+/**
+ * Asserts that tabs that have been recently closed can be
+ * restored by clicking on the list item
+ */
+add_task(async function test_restore_tab() {
+ let { cleanup, expectedURLs } = await prepareClosedTabs();
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ navigateToCategory(document, "recentlyclosed");
+
+ let [listElem, listItems] = await waitForRecentlyClosedTabsList(document);
+ Assert.deepEqual(
+ Array.from(listItems).map(el => el.url),
+ expectedURLs,
+ "The initial list has rendered the expected tab items in the right order"
+ );
+ let closeTabItem = listItems[0];
+ info(
+ `Restoring the first closed tab ${closeTabItem.url}, closedId: ${closeTabItem.closedId}, sourceClosedId: ${closeTabItem.sourceClosedId} and waiting for sessionstore-closed-objects-changed`
+ );
+ await clearAllParentTelemetryEvents();
+ await restore_tab(closeTabItem, browser, closeTabItem.url);
+ await recentlyClosedTelemetry();
+ await clickFirefoxViewButton(window);
+
+ listItems = listElem.rowEls;
+ is(listItems.length, 3, "Three tabs are shown in the list.");
+
+ closeTabItem = listItems[listItems.length - 1];
+ await clearAllParentTelemetryEvents();
+ await restore_tab(closeTabItem, browser, closeTabItem.url);
+ await recentlyClosedTelemetry();
+ await clickFirefoxViewButton(window);
+
+ listItems = listElem.rowEls;
+ is(listItems.length, 2, "Two tabs are shown in the list.");
+
+ listItems = listElem.rowEls;
+ is(listItems.length, 2, "Two tabs are shown in the list.");
+ });
+ await cleanup();
+});
+
+/**
+ * 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() {
+ let { cleanup, expectedURLs } = await prepareClosedTabs();
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ navigateToCategory(document, "recentlyclosed");
+
+ let [listElem, listItems] = await waitForRecentlyClosedTabsList(document);
+ await clearAllParentTelemetryEvents();
+
+ info("calling dismiss_tab on the top, most-recently closed tab");
+ let closedTabItem = listItems[0];
+
+ // dismiss the first tab and verify the list is correctly updated
+ await dismiss_tab(closedTabItem);
+ await listElem.getUpdateComplete;
+
+ info("check telemetry results");
+ await recentlyClosedDismissTelemetry();
+
+ listItems = listElem.rowEls;
+ expectedURLs.shift(); // we expect to have removed the first URL from the list
+ Assert.deepEqual(
+ Array.from(listItems).map(el => el.url),
+ expectedURLs,
+ "After dismissing the most-recent tab we have expected tab items in the right order"
+ );
+
+ // dismiss the last tab and verify the list is correctly updated
+ closedTabItem = listItems[listItems.length - 1];
+ await dismiss_tab(closedTabItem);
+ await listElem.getUpdateComplete;
+
+ listItems = listElem.rowEls;
+ expectedURLs.pop(); // we expect to have removed the last URL from the list
+ let actualClosedTabCount =
+ SessionStore.getClosedTabCount(window) +
+ SessionStore.getClosedTabCountFromClosedWindows();
+ Assert.equal(
+ actualClosedTabCount,
+ 2,
+ "After dismissing the least-recent tab, SessionStore has 2 left"
+ );
+ Assert.deepEqual(
+ Array.from(listItems).map(el => el.url),
+ expectedURLs,
+ "After dismissing the least-recent tab we have expected tab items in the right order"
+ );
+ });
+ await cleanup();
+});
+
+add_task(async function test_empty_states() {
+ Services.obs.notifyObservers(null, "browser:purge-session-history");
+ is(
+ SessionStore.getClosedTabCount(window),
+ 0,
+ "Closed tab count after purging session history"
+ );
+ is(
+ SessionStore.getClosedTabCountFromClosedWindows(),
+ 0,
+ "Closed tabs-from-closed-windows count after purging session history"
+ );
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ is(document.location.href, "about:firefoxview");
+
+ navigateToCategory(document, "recentlyclosed");
+ let recentlyClosedComponent = document.querySelector(
+ "view-recentlyclosed:not([slot=recentlyclosed])"
+ );
+
+ await TestUtils.waitForCondition(() => recentlyClosedComponent.emptyState);
+ let emptyStateCard = recentlyClosedComponent.emptyState;
+ ok(
+ emptyStateCard.headerEl.textContent.includes("Closed a tab too soon"),
+ "Initial empty state header has the expected text."
+ );
+ ok(
+ emptyStateCard.descriptionEls[0].textContent.includes(
+ "Here you’ll find the tabs you recently closed"
+ ),
+ "Initial empty state description has the expected text."
+ );
+
+ // Test empty state when History mode is set to never remember
+ Services.prefs.setBoolPref(NEVER_REMEMBER_HISTORY_PREF, true);
+ // Manually update the recentlyclosed component from the test, since changing this setting
+ // in about:preferences will require a browser reload
+ recentlyClosedComponent.requestUpdate();
+ await TestUtils.waitForCondition(
+ () => recentlyClosedComponent.fullyUpdated
+ );
+ emptyStateCard = recentlyClosedComponent.emptyState;
+ ok(
+ emptyStateCard.headerEl.textContent.includes("Nothing to show"),
+ "Empty state with never remember history header has the expected text."
+ );
+ ok(
+ emptyStateCard.descriptionEls[1].textContent.includes(
+ "remember your activity as you browse. To change that"
+ ),
+ "Empty state with never remember history description has the expected text."
+ );
+ // Reset History mode to Remember
+ Services.prefs.setBoolPref(NEVER_REMEMBER_HISTORY_PREF, false);
+ gBrowser.removeTab(gBrowser.selectedTab);
+ });
+});
+
+add_task(async function test_observers_removed_when_view_is_hidden() {
+ clearHistory();
+
+ await open_then_close(URLs[0]);
+
+ await withFirefoxView({}, async function (browser) {
+ const { document } = browser.contentWindow;
+ navigateToCategory(document, "recentlyclosed");
+ const [listElem] = await waitForRecentlyClosedTabsList(document);
+ is(listElem.rowEls.length, 1);
+
+ const gBrowser = browser.getTabBrowser();
+ const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, URLs[1]);
+ await open_then_close(URLs[2]);
+ await open_then_close(URLs[3]);
+ await open_then_close(URLs[4]);
+ is(
+ listElem.rowEls.length,
+ 1,
+ "The list does not update when Firefox View is hidden."
+ );
+
+ await switchToFxViewTab(browser.ownerGlobal);
+ info("The list should update when Firefox View is visible.");
+ await BrowserTestUtils.waitForMutationCondition(
+ listElem,
+ { childList: true },
+ () => listElem.rowEls.length === 4
+ );
+
+ BrowserTestUtils.removeTab(tab);
+ });
+});
+
+add_task(async function test_search() {
+ let { cleanup, expectedURLs } = await prepareClosedTabs();
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ navigateToCategory(document, "recentlyclosed");
+ const [listElem] = await waitForRecentlyClosedTabsList(document);
+ const recentlyClosedComponent = document.querySelector(
+ "view-recentlyclosed:not([slot=recentlyclosed])"
+ );
+ const { searchTextbox, tabList } = recentlyClosedComponent;
+
+ info("Input a search query.");
+ EventUtils.synthesizeMouseAtCenter(searchTextbox, {}, content);
+ EventUtils.sendString("example.com", content);
+ await TestUtils.waitForCondition(
+ () => listElem.rowEls.length === 1,
+ "There is one matching search result."
+ );
+
+ info("Clear the search query.");
+ EventUtils.synthesizeMouseAtCenter(searchTextbox.clearButton, {}, content);
+ await TestUtils.waitForCondition(
+ () => listElem.rowEls.length === expectedURLs.length,
+ "The original list is restored."
+ );
+ searchTextbox.blur();
+
+ info("Input a bogus search query with keyboard.");
+ EventUtils.synthesizeKey("f", { accelKey: true }, content);
+ EventUtils.sendString("Bogus Query", content);
+ await TestUtils.waitForCondition(
+ () => tabList.shadowRoot.querySelector("fxview-empty-state"),
+ "There are no matching search results."
+ );
+
+ info("Clear the search query with keyboard.");
+ EventUtils.synthesizeMouseAtCenter(searchTextbox.clearButton, {}, content);
+
+ is(
+ recentlyClosedComponent.shadowRoot.activeElement,
+ searchTextbox,
+ "Search input is focused"
+ );
+ EventUtils.synthesizeKey("KEY_Tab", {}, content);
+ EventUtils.synthesizeKey("KEY_Enter", {}, content);
+ await TestUtils.waitForCondition(
+ () => listElem.rowEls.length === expectedURLs.length,
+ "The original list is restored."
+ );
+ });
+ await cleanup();
+});
+
+add_task(async function test_search_recent_browsing() {
+ const NUMBER_OF_TABS = 6;
+ clearHistory();
+ for (let i = 0; i < NUMBER_OF_TABS; i++) {
+ await open_then_close(URLs[1]);
+ }
+ await withFirefoxView({}, async function (browser) {
+ const { document } = browser.contentWindow;
+
+ info("Input a search query.");
+ await navigateToCategoryAndWait(document, "recentbrowsing");
+ const recentBrowsing = document.querySelector("view-recentbrowsing");
+ EventUtils.synthesizeMouseAtCenter(
+ recentBrowsing.searchTextbox,
+ {},
+ content
+ );
+ EventUtils.sendString("example.com", content);
+ const slot = recentBrowsing.querySelector("[slot='recentlyclosed']");
+ await TestUtils.waitForCondition(
+ () =>
+ slot.tabList.rowEls.length === 5 &&
+ slot.shadowRoot.querySelector("[data-l10n-id='firefoxview-show-all']"),
+ "Not all search results are shown yet."
+ );
+
+ info("Click the Show All link.");
+ const showAllLink = slot.shadowRoot.querySelector(
+ "[data-l10n-id='firefoxview-show-all']"
+ );
+ is(showAllLink.role, "link", "The show all control is a link.");
+ EventUtils.synthesizeMouseAtCenter(showAllLink, {}, content);
+ await TestUtils.waitForCondition(
+ () => slot.tabList.rowEls.length === NUMBER_OF_TABS,
+ "All search results are shown."
+ );
+ ok(BrowserTestUtils.isHidden(showAllLink), "The show all link is hidden.");
+ });
+});
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_syncedtabs_errors_firefoxview.js b/browser/components/firefoxview/tests/browser/browser_syncedtabs_errors_firefoxview.js
new file mode 100644
index 0000000000..15dba68551
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_syncedtabs_errors_firefoxview.js
@@ -0,0 +1,141 @@
+/* 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",
+ tabs: [],
+ },
+ {
+ id: 2,
+ name: "Other Device",
+ type: "desktop",
+ tabs: [],
+ },
+ ],
+ });
+ 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();
+ sandbox.spy(TabsSetupFlowManager, "tryToClearError");
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "syncedtabs");
+
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+ Services.obs.notifyObservers(
+ null,
+ "network:offline-status-changed",
+ "offline"
+ );
+
+ let syncedTabsComponent = document.querySelector(
+ "view-syncedtabs:not([slot=syncedtabs])"
+ );
+ await TestUtils.waitForCondition(() => syncedTabsComponent.fullyUpdated);
+ await BrowserTestUtils.waitForMutationCondition(
+ syncedTabsComponent.shadowRoot.querySelector(".cards-container"),
+ { childList: true },
+ () => syncedTabsComponent.shadowRoot.innerHTML.includes("network-offline")
+ );
+
+ let emptyState =
+ syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state");
+ ok(
+ emptyState.getAttribute("headerlabel").includes("network-offline"),
+ "Network offline message is shown"
+ );
+ emptyState.querySelector("button[data-action='network-offline']").click();
+
+ await BrowserTestUtils.waitForCondition(
+ () => TabsSetupFlowManager.tryToClearError.calledOnce
+ );
+
+ ok(
+ TabsSetupFlowManager.tryToClearError.calledOnce,
+ "TabsSetupFlowManager.tryToClearError() was called once"
+ );
+
+ emptyState =
+ syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state");
+ ok(
+ emptyState.getAttribute("headerlabel").includes("network-offline"),
+ "Network offline message is still shown"
+ );
+
+ Services.obs.notifyObservers(
+ null,
+ "network:offline-status-changed",
+ "online"
+ );
+ });
+ await tearDown(sandbox);
+});
+
+add_task(async function test_sync_error() {
+ const sandbox = await setupWithDesktopDevices();
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "syncedtabs");
+
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+ Services.obs.notifyObservers(null, "weave:service:sync:error");
+
+ let syncedTabsComponent = document.querySelector(
+ "view-syncedtabs:not([slot=syncedtabs])"
+ );
+ await TestUtils.waitForCondition(() => syncedTabsComponent.fullyUpdated);
+ await BrowserTestUtils.waitForMutationCondition(
+ syncedTabsComponent.shadowRoot.querySelector(".cards-container"),
+ { childList: true },
+ () => syncedTabsComponent.shadowRoot.innerHTML.includes("sync-error")
+ );
+
+ let emptyState =
+ syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state");
+ ok(
+ emptyState.getAttribute("headerlabel").includes("sync-error"),
+ "Correct message should show when there's a sync service error"
+ );
+
+ // Clear the error.
+ Services.obs.notifyObservers(null, "weave:service:sync:finish");
+ });
+ await tearDown(sandbox);
+});
diff --git a/browser/components/firefoxview/tests/browser/browser_syncedtabs_firefoxview.js b/browser/components/firefoxview/tests/browser/browser_syncedtabs_firefoxview.js
new file mode 100644
index 0000000000..8a3c63985b
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/browser_syncedtabs_firefoxview.js
@@ -0,0 +1,747 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+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 () {
+ await tearDown(gSandbox);
+ });
+});
+
+async function promiseTabListsUpdated({ tabLists }) {
+ for (const tabList of tabLists) {
+ await tabList.updateComplete;
+ }
+ await TestUtils.waitForTick();
+}
+
+add_task(async function test_unconfigured_initial_state() {
+ const sandbox = setupMocks({
+ state: UIState.STATUS_NOT_CONFIGURED,
+ syncEnabled: false,
+ });
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "syncedtabs");
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ let syncedTabsComponent = document.querySelector(
+ "view-syncedtabs:not([slot=syncedtabs])"
+ );
+
+ let emptyState =
+ syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state");
+ ok(
+ emptyState.getAttribute("headerlabel").includes("syncedtabs-signin"),
+ "Signin message is shown"
+ );
+
+ // Test telemetry for signing into Firefox Accounts.
+ await clearAllParentTelemetryEvents();
+ EventUtils.synthesizeMouseAtCenter(
+ emptyState.querySelector(`button[data-action="sign-in"]`),
+ {},
+ browser.contentWindow
+ );
+ await TestUtils.waitForCondition(
+ () =>
+ Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS
+ ).parent?.length >= 1,
+ "Waiting for fxa_continue firefoxview telemetry event.",
+ 200,
+ 100
+ );
+ TelemetryTestUtils.assertEvents(
+ [["firefoxview_next", "fxa_continue", "sync"]],
+ { category: "firefoxview_next" },
+ { clear: true, process: "parent" }
+ );
+ await BrowserTestUtils.removeTab(browser.ownerGlobal.gBrowser.selectedTab);
+ });
+ await tearDown(sandbox);
+});
+
+add_task(async function test_signed_in() {
+ const sandbox = setupMocks({
+ state: UIState.STATUS_SIGNED_IN,
+ fxaDevices: [
+ {
+ id: 1,
+ name: "This Device",
+ isCurrentDevice: true,
+ type: "desktop",
+ tabs: [],
+ },
+ ],
+ });
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "syncedtabs");
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ let syncedTabsComponent = document.querySelector(
+ "view-syncedtabs:not([slot=syncedtabs])"
+ );
+ await TestUtils.waitForCondition(() => syncedTabsComponent.fullyUpdated);
+ let emptyState =
+ syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state");
+ ok(
+ emptyState.getAttribute("headerlabel").includes("syncedtabs-adddevice"),
+ "Add device message is shown"
+ );
+
+ // Test telemetry for adding a device.
+ await clearAllParentTelemetryEvents();
+ EventUtils.synthesizeMouseAtCenter(
+ emptyState.querySelector(`button[data-action="add-device"]`),
+ {},
+ browser.contentWindow
+ );
+ await TestUtils.waitForCondition(
+ () =>
+ Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS
+ ).parent?.length >= 1,
+ "Waiting for fxa_mobile firefoxview telemetry event.",
+ 200,
+ 100
+ );
+ TelemetryTestUtils.assertEvents(
+ [["firefoxview_next", "fxa_mobile", "sync"]],
+ { category: "firefoxview_next" },
+ { clear: true, process: "parent" }
+ );
+ await BrowserTestUtils.removeTab(browser.ownerGlobal.gBrowser.selectedTab);
+ });
+ await tearDown(sandbox);
+});
+
+add_task(async function test_no_synced_tabs() {
+ Services.prefs.setBoolPref("services.sync.engine.tabs", false);
+ const sandbox = setupMocks({
+ state: UIState.STATUS_SIGNED_IN,
+ fxaDevices: [
+ {
+ id: 1,
+ name: "This Device",
+ isCurrentDevice: true,
+ type: "desktop",
+ tabs: [],
+ },
+ {
+ id: 2,
+ name: "Other Device",
+ type: "desktop",
+ tabs: [],
+ },
+ ],
+ });
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "syncedtabs");
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ let syncedTabsComponent = document.querySelector(
+ "view-syncedtabs:not([slot=syncedtabs])"
+ );
+ await TestUtils.waitForCondition(() => syncedTabsComponent.fullyUpdated);
+ let emptyState =
+ syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state");
+ ok(
+ emptyState.getAttribute("headerlabel").includes("syncedtabs-synctabs"),
+ "Enable synced tabs message is shown"
+ );
+ });
+ await tearDown(sandbox);
+ Services.prefs.setBoolPref("services.sync.engine.tabs", true);
+});
+
+add_task(async function test_no_error_for_two_desktop() {
+ const sandbox = setupMocks({
+ state: UIState.STATUS_SIGNED_IN,
+ fxaDevices: [
+ {
+ id: 1,
+ name: "This Device",
+ isCurrentDevice: true,
+ type: "desktop",
+ tabs: [],
+ },
+ {
+ id: 2,
+ name: "Other Device",
+ type: "desktop",
+ tabs: [],
+ },
+ ],
+ });
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "syncedtabs");
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ let syncedTabsComponent = document.querySelector(
+ "view-syncedtabs:not([slot=syncedtabs])"
+ );
+ await TestUtils.waitForCondition(() => syncedTabsComponent.fullyUpdated);
+ let emptyState =
+ syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state");
+ is(emptyState, null, "No empty state should be shown");
+ let noTabs = syncedTabsComponent.shadowRoot.querySelectorAll(".notabs");
+ is(noTabs.length, 1, "Should be 1 empty device");
+ });
+ await tearDown(sandbox);
+});
+
+add_task(async function test_empty_state() {
+ const sandbox = setupMocks({
+ state: UIState.STATUS_SIGNED_IN,
+ fxaDevices: [
+ {
+ id: 1,
+ name: "This Device",
+ isCurrentDevice: true,
+ type: "desktop",
+ tabs: [],
+ },
+ {
+ id: 2,
+ name: "Other Desktop",
+ type: "desktop",
+ tabs: [],
+ },
+ {
+ id: 3,
+ name: "Other Mobile",
+ type: "phone",
+ tabs: [],
+ },
+ ],
+ });
+
+ await withFirefoxView({ openNewWindow: true }, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "syncedtabs");
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ let syncedTabsComponent = document.querySelector(
+ "view-syncedtabs:not([slot=syncedtabs])"
+ );
+ await TestUtils.waitForCondition(() => syncedTabsComponent.fullyUpdated);
+ let noTabs = syncedTabsComponent.shadowRoot.querySelectorAll(".notabs");
+ is(noTabs.length, 2, "Should be 2 empty devices");
+
+ let headers =
+ syncedTabsComponent.shadowRoot.querySelectorAll("h3[slot=header]");
+ ok(
+ headers[0].textContent.includes("Other Desktop"),
+ "Text is correct (Desktop)"
+ );
+ ok(headers[0].innerHTML.includes("icon desktop"), "Icon should be desktop");
+ ok(
+ headers[1].textContent.includes("Other Mobile"),
+ "Text is correct (Mobile)"
+ );
+ ok(headers[1].innerHTML.includes("icon phone"), "Icon should be phone");
+ });
+ await tearDown(sandbox);
+});
+
+add_task(async function test_tabs() {
+ 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);
+ });
+ sandbox.stub(SyncedTabs, "getTabClients").callsFake(() => {
+ return Promise.resolve(syncedTabsData1);
+ });
+
+ await withFirefoxView({ openNewWindow: true }, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "syncedtabs");
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ let syncedTabsComponent = document.querySelector(
+ "view-syncedtabs:not([slot=syncedtabs])"
+ );
+ await TestUtils.waitForCondition(() => syncedTabsComponent.fullyUpdated);
+
+ let headers =
+ syncedTabsComponent.shadowRoot.querySelectorAll("h3[slot=header]");
+ ok(
+ headers[0].textContent.includes("My desktop"),
+ "Text is correct (My desktop)"
+ );
+ ok(headers[0].innerHTML.includes("icon desktop"), "Icon should be desktop");
+ ok(
+ headers[1].textContent.includes("My iphone"),
+ "Text is correct (My iphone)"
+ );
+ ok(headers[1].innerHTML.includes("icon phone"), "Icon should be phone");
+
+ let tabLists = syncedTabsComponent.tabLists;
+ await TestUtils.waitForCondition(() => {
+ return tabLists[0].rowEls.length;
+ });
+ let tabRow1 = tabLists[0].rowEls;
+ ok(
+ tabRow1[0].shadowRoot.textContent.includes,
+ "Internet for people, not profits - Mozilla"
+ );
+ ok(tabRow1[1].shadowRoot.textContent.includes, "Sandboxes - Sinon.JS");
+ is(tabRow1.length, 2, "Correct number of rows are displayed.");
+ let tabRow2 = tabLists[1].shadowRoot.querySelectorAll("fxview-tab-row");
+ is(tabRow2.length, 2, "Correct number of rows are dispayed.");
+ ok(tabRow1[0].shadowRoot.textContent.includes, "The Guardian");
+ ok(tabRow1[1].shadowRoot.textContent.includes, "The Times");
+
+ // Test telemetry for opening a tab.
+ await clearAllParentTelemetryEvents();
+ EventUtils.synthesizeMouseAtCenter(tabRow1[0], {}, browser.contentWindow);
+ await TestUtils.waitForCondition(
+ () =>
+ Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS
+ ).parent?.length >= 1,
+ "Waiting for synced_tabs firefoxview telemetry event.",
+ 200,
+ 100
+ );
+ TelemetryTestUtils.assertEvents(
+ [
+ [
+ "firefoxview_next",
+ "synced_tabs",
+ "tabs",
+ null,
+ { page: "syncedtabs" },
+ ],
+ ],
+ { category: "firefoxview_next" },
+ { clear: true, process: "parent" }
+ );
+ });
+ await tearDown(sandbox);
+});
+
+add_task(async function test_empty_desktop_same_name() {
+ const sandbox = setupMocks({
+ state: UIState.STATUS_SIGNED_IN,
+ fxaDevices: [
+ {
+ id: 1,
+ name: "A Device",
+ isCurrentDevice: true,
+ type: "desktop",
+ tabs: [],
+ },
+ {
+ id: 2,
+ name: "A Device",
+ type: "desktop",
+ tabs: [],
+ },
+ ],
+ });
+
+ await withFirefoxView({ openNewWindow: true }, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "syncedtabs");
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ let syncedTabsComponent = document.querySelector(
+ "view-syncedtabs:not([slot=syncedtabs])"
+ );
+ await TestUtils.waitForCondition(() => syncedTabsComponent.fullyUpdated);
+ let noTabs = syncedTabsComponent.shadowRoot.querySelectorAll(".notabs");
+ is(noTabs.length, 1, "Should be 1 empty devices");
+
+ let headers =
+ syncedTabsComponent.shadowRoot.querySelectorAll("h3[slot=header]");
+ ok(
+ headers[0].textContent.includes("A Device"),
+ "Text is correct (Desktop)"
+ );
+ });
+ await tearDown(sandbox);
+});
+
+add_task(async function test_empty_desktop_same_name_three() {
+ const sandbox = setupMocks({
+ state: UIState.STATUS_SIGNED_IN,
+ fxaDevices: [
+ {
+ id: 1,
+ name: "A Device",
+ isCurrentDevice: true,
+ type: "desktop",
+ tabs: [],
+ },
+ {
+ id: 2,
+ name: "A Device",
+ type: "desktop",
+ tabs: [],
+ },
+ {
+ id: 3,
+ name: "A Device",
+ type: "desktop",
+ tabs: [],
+ },
+ ],
+ });
+
+ await withFirefoxView({ openNewWindow: true }, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "syncedtabs");
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ let syncedTabsComponent = document.querySelector(
+ "view-syncedtabs:not([slot=syncedtabs])"
+ );
+ await TestUtils.waitForCondition(() => syncedTabsComponent.fullyUpdated);
+ let noTabs = syncedTabsComponent.shadowRoot.querySelectorAll(".notabs");
+ is(noTabs.length, 2, "Should be 2 empty devices");
+
+ let headers =
+ syncedTabsComponent.shadowRoot.querySelectorAll("h3[slot=header]");
+ ok(
+ headers[0].textContent.includes("A Device"),
+ "Text is correct (Desktop)"
+ );
+ ok(
+ headers[1].textContent.includes("A Device"),
+ "Text is correct (Desktop)"
+ );
+ });
+ await tearDown(sandbox);
+});
+
+add_task(async function search_synced_tabs() {
+ 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);
+ });
+ sandbox.stub(SyncedTabs, "getTabClients").callsFake(() => {
+ return Promise.resolve(syncedTabsData1);
+ });
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.firefox-view.search.enabled", true]],
+ });
+
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "syncedtabs");
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ let syncedTabsComponent = document.querySelector(
+ "view-syncedtabs:not([slot=syncedtabs])"
+ );
+ await TestUtils.waitForCondition(() => syncedTabsComponent.fullyUpdated);
+
+ is(syncedTabsComponent.cardEls.length, 2, "There are two device cards.");
+ await TestUtils.waitForCondition(
+ () =>
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list") &&
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls
+ .length &&
+ syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list") &&
+ syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list").rowEls
+ .length,
+ "The tab list has loaded for the first two cards."
+ );
+ let deviceOneTabs =
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls;
+ let deviceTwoTabs =
+ syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list").rowEls;
+
+ info("Input a search query.");
+ EventUtils.synthesizeMouseAtCenter(
+ syncedTabsComponent.searchTextbox,
+ {},
+ content
+ );
+ EventUtils.sendString("Mozilla", content);
+ await TestUtils.waitForCondition(
+ () => syncedTabsComponent.fullyUpdated,
+ "Synced Tabs component is done updating."
+ );
+ await TestUtils.waitForCondition(
+ () =>
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list") &&
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls
+ .length,
+ "The tab list has loaded for the first card."
+ );
+ await TestUtils.waitForCondition(
+ () =>
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls
+ .length === 1,
+ "There is one matching search result for the first device."
+ );
+ await TestUtils.waitForCondition(
+ () => !syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list"),
+ "There are no matching search results for the second device."
+ );
+
+ info("Clear the search query.");
+ EventUtils.synthesizeMouseAtCenter(
+ syncedTabsComponent.searchTextbox.clearButton,
+ {},
+ content
+ );
+ await TestUtils.waitForCondition(
+ () => syncedTabsComponent.fullyUpdated,
+ "Synced Tabs component is done updating."
+ );
+ await TestUtils.waitForCondition(
+ () =>
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list") &&
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls
+ .length &&
+ syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list") &&
+ syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list").rowEls
+ .length,
+ "The tab list has loaded for the first two cards."
+ );
+ deviceOneTabs =
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls;
+ deviceTwoTabs =
+ syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list").rowEls;
+ await TestUtils.waitForCondition(
+ () =>
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls
+ .length === deviceOneTabs.length,
+ "The original device's list is restored."
+ );
+ await TestUtils.waitForCondition(
+ () =>
+ syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list").rowEls
+ .length === deviceTwoTabs.length,
+ "The new devices's list is restored."
+ );
+ syncedTabsComponent.searchTextbox.blur();
+
+ info("Input a search query with keyboard.");
+ EventUtils.synthesizeKey("f", { accelKey: true }, content);
+ EventUtils.sendString("Mozilla", content);
+ await TestUtils.waitForCondition(
+ () => syncedTabsComponent.fullyUpdated,
+ "Synced Tabs component is done updating."
+ );
+ await TestUtils.waitForCondition(
+ () =>
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list") &&
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls
+ .length,
+ "The tab list has loaded for the first card."
+ );
+ await TestUtils.waitForCondition(() => {
+ return (
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls
+ .length === 1
+ );
+ }, "There is one matching search result for the first device.");
+ await TestUtils.waitForCondition(
+ () => !syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list"),
+ "There are no matching search results for the second device."
+ );
+
+ info("Clear the search query with keyboard.");
+ is(
+ syncedTabsComponent.shadowRoot.activeElement,
+ syncedTabsComponent.searchTextbox,
+ "Search input is focused"
+ );
+ EventUtils.synthesizeKey("KEY_Tab", {}, content);
+ ok(
+ syncedTabsComponent.searchTextbox.clearButton.matches(":focus-visible"),
+ "Clear Search button is focused"
+ );
+ EventUtils.synthesizeKey("KEY_Enter", {}, content);
+ await TestUtils.waitForCondition(
+ () => syncedTabsComponent.fullyUpdated,
+ "Synced Tabs component is done updating."
+ );
+ await TestUtils.waitForCondition(
+ () =>
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list") &&
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls
+ .length &&
+ syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list") &&
+ syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list").rowEls
+ .length,
+ "The tab list has loaded for the first two cards."
+ );
+ deviceOneTabs =
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls;
+ deviceTwoTabs =
+ syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list").rowEls;
+ await TestUtils.waitForCondition(
+ () =>
+ syncedTabsComponent.cardEls[0].querySelector("fxview-tab-list").rowEls
+ .length === deviceOneTabs.length,
+ "The original device's list is restored."
+ );
+ await TestUtils.waitForCondition(
+ () =>
+ syncedTabsComponent.cardEls[1].querySelector("fxview-tab-list").rowEls
+ .length === deviceTwoTabs.length,
+ "The new devices's list is restored."
+ );
+ });
+ await SpecialPowers.popPrefEnv();
+ await tearDown(sandbox);
+});
+
+add_task(async function search_synced_tabs_recent_browsing() {
+ const NUMBER_OF_TABS = 6;
+ TabsSetupFlowManager.resetInternalState();
+ const sandbox = setupRecentDeviceListMocks();
+ const tabClients = [
+ {
+ id: 1,
+ type: "client",
+ name: "My desktop",
+ clientType: "desktop",
+ tabs: Array(NUMBER_OF_TABS).fill({
+ 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",
+ client: 1,
+ }),
+ },
+ {
+ id: 2,
+ type: "client",
+ name: "My iphone",
+ clientType: "phone",
+ tabs: [
+ {
+ type: "tab",
+ title: "Mount Everest - Wikipedia",
+ url: "https://en.wikipedia.org/wiki/Mount_Everest",
+ icon: "https://www.wikipedia.org/static/favicon/wikipedia.ico",
+ client: 2,
+ },
+ ],
+ },
+ ];
+ sandbox
+ .stub(SyncedTabs, "getRecentTabs")
+ .resolves(getMockTabData(tabClients));
+ sandbox.stub(SyncedTabs, "getTabClients").resolves(tabClients);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.firefox-view.search.enabled", true]],
+ });
+ await withFirefoxView({}, async browser => {
+ const { document } = browser.contentWindow;
+ await navigateToCategoryAndWait(document, "recentbrowsing");
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ const recentBrowsing = document.querySelector("view-recentbrowsing");
+ const slot = recentBrowsing.querySelector("[slot='syncedtabs']");
+
+ // Test that all tab lists repopulate when clearing out searched terms (Bug 1869895 & Bug 1873212)
+ info("Input a search query");
+ EventUtils.synthesizeMouseAtCenter(
+ recentBrowsing.searchTextbox,
+ {},
+ content
+ );
+ EventUtils.sendString("Mozilla", content);
+ await TestUtils.waitForCondition(
+ () => slot.fullyUpdated && slot.tabLists.length === 1,
+ "Synced Tabs component is done updating."
+ );
+ await promiseTabListsUpdated(slot);
+ info("Scroll first card into view.");
+ slot.tabLists[0].scrollIntoView();
+ await TestUtils.waitForCondition(
+ () => slot.tabLists[0].rowEls.length === 5,
+ "The first card is populated."
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ recentBrowsing.searchTextbox,
+ {},
+ content
+ );
+ EventUtils.synthesizeKey("KEY_Backspace", { repeat: 5 });
+ await TestUtils.waitForCondition(
+ () => slot.fullyUpdated && slot.tabLists.length === 2,
+ "Synced Tabs component is done updating."
+ );
+ await promiseTabListsUpdated(slot);
+ info("Scroll second card into view.");
+ slot.tabLists[1].scrollIntoView();
+ await TestUtils.waitForCondition(
+ () =>
+ slot.tabLists[0].rowEls.length === 5 &&
+ slot.tabLists[1].rowEls.length === 1,
+ "Both cards are populated."
+ );
+ info("Clear the search query.");
+ EventUtils.synthesizeKey("KEY_Backspace", { repeat: 2 });
+
+ info("Input a search query");
+ EventUtils.synthesizeMouseAtCenter(
+ recentBrowsing.searchTextbox,
+ {},
+ content
+ );
+ EventUtils.sendString("Mozilla", content);
+ await TestUtils.waitForCondition(
+ () => slot.fullyUpdated && slot.tabLists.length === 2,
+ "Synced Tabs component is done updating."
+ );
+ await promiseTabListsUpdated(slot);
+ await TestUtils.waitForCondition(
+ () => slot.tabLists[0].rowEls.length === 5,
+ "Not all search results are shown yet."
+ );
+
+ info("Click the Show All link.");
+ const showAllLink = await TestUtils.waitForCondition(() =>
+ slot.shadowRoot.querySelector("[data-l10n-id='firefoxview-show-all']")
+ );
+ is(showAllLink.role, "link", "The show all control is a link.");
+ EventUtils.synthesizeMouseAtCenter(showAllLink, {}, content);
+ await TestUtils.waitForCondition(
+ () => slot.tabLists[0].rowEls.length === NUMBER_OF_TABS,
+ "All search results are shown."
+ );
+ ok(BrowserTestUtils.isHidden(showAllLink), "The show all link is hidden.");
+ });
+ await SpecialPowers.popPrefEnv();
+ await tearDown(sandbox);
+});
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..e7aed1c429
--- /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.startLoadingURIString(win.gBrowser.selectedBrowser, URL);
+ await loaded;
+ info("Opening new browser tab...");
+ const secondTab = await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ URL
+ );
+ info("Close all browser 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/head.js b/browser/components/firefoxview/tests/browser/head.js
new file mode 100644
index 0000000000..b0b41b759d
--- /dev/null
+++ b/browser/components/firefoxview/tests/browser/head.js
@@ -0,0 +1,708 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const {
+ getFirefoxViewURL,
+ withFirefoxView,
+ assertFirefoxViewTab,
+ assertFirefoxViewTabSelected,
+ openFirefoxViewTab,
+ closeFirefoxViewTab,
+ isFirefoxViewTabSelectedInWindow,
+ init: FirefoxViewTestUtilsInit,
+} = ChromeUtils.importESModule(
+ "resource://testing-common/FirefoxViewTestUtils.sys.mjs"
+);
+
+/* exported testVisibility */
+
+const { ASRouter } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/ASRouter.sys.mjs"
+);
+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:///modules/asrouter/FeatureCalloutMessages.sys.mjs"
+);
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+const triggeringPrincipal_base64 = E10SUtils.SERIALIZED_SYSTEMPRINCIPAL;
+const { SessionStoreTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/SessionStoreTestUtils.sys.mjs"
+);
+SessionStoreTestUtils.init(this, window);
+FirefoxViewTestUtilsInit(this, window);
+
+ChromeUtils.defineESModuleGetters(this, {
+ AboutWelcomeParent: "resource:///actors/AboutWelcomeParent.sys.mjs",
+ BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs",
+ SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs",
+ TabStateFlusher: "resource:///modules/sessionstore/TabStateFlusher.sys.mjs",
+});
+
+const calloutId = "feature-callout";
+const calloutSelector = `#${calloutId}.featureCallout`;
+const CTASelector = `#${calloutId} :is(.primary, .secondary)`;
+
+/**
+ * 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/",
+ "about:robots",
+ "https://www.mozilla.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
+ client: 1,
+ },
+ {
+ 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
+ client: 1,
+ },
+ ],
+ },
+ {
+ 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
+ client: 2,
+ },
+ {
+ 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
+ client: 2,
+ },
+ ],
+ },
+];
+
+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.isVisible(elem),
+ `Expected ${selector} to be visible`
+ );
+ } else {
+ ok(BrowserTestUtils.isHidden(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.isVisible(elem)
+ : BrowserTestUtils.isHidden(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.isVisible(nextStepElem);
+ }
+ );
+
+ for (let elem of stepElems) {
+ if (elem == nextStepElem) {
+ ok(
+ BrowserTestUtils.isVisible(elem),
+ `Expected ${elem.id || elem.className} to be visible`
+ );
+ } else {
+ ok(
+ BrowserTestUtils.isHidden(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, 10);
+}
+
+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");
+}
+
+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,
+ }),
+ };
+ });
+ // This is converting the device list to a client list.
+ // There are two primary differences:
+ // 1. The client list doesn't return the current device.
+ // 2. It uses clientType instead of type.
+ let tabClients = fxaDevices ? [...fxaDevices] : [];
+ for (let client of tabClients) {
+ client.clientType = client.type;
+ }
+ tabClients = tabClients.filter(device => !device.isCurrentDevice);
+ sandbox.stub(SyncedTabs, "getTabClients").callsFake(() => {
+ return Promise.resolve(tabClients);
+ });
+ return sandbox;
+}
+
+async function tearDown(sandbox) {
+ sandbox?.restore();
+ Services.prefs.clearUserPref("services.sync.lastTabFetch");
+}
+
+const featureTourPref = "browser.firefox-view.feature-tour";
+const launchFeatureTourIn = win => {
+ const { FeatureCallout } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/FeatureCallout.sys.mjs"
+ );
+ let callout = new FeatureCallout({
+ win,
+ pref: { name: featureTourPref },
+ location: "about:firefoxview",
+ context: "content",
+ theme: { preset: "themed-content" },
+ });
+ callout.showFeatureCallout();
+ return callout;
+};
+
+/**
+ * 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
+ * @returns {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 clickCTA = async doc => {
+ doc.querySelector(CTASelector).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} id
+ * The 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
+ * @returns {Promise} Promise that resolves when the session store
+ * has been updated after closing the tab.
+ */
+async function open_then_close(url, win = window) {
+ return SessionStoreTestUtils.openAndCloseTab(win, url);
+}
+
+/**
+ * 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");
+}
+
+function isFirefoxViewTabSelected(win = window) {
+ return isFirefoxViewTabSelectedInWindow(win);
+}
+
+function promiseAllButPrimaryWindowClosed() {
+ let windows = [];
+ for (let win of BrowserWindowTracker.orderedWindows) {
+ if (win != window) {
+ windows.push(win);
+ }
+ }
+ return Promise.all(windows.map(BrowserTestUtils.closeWindow));
+}
+
+registerCleanupFunction(() => {
+ // ensure all the stubs are restored, regardless of any exceptions
+ // that might have prevented it
+ gSandbox?.restore();
+});
+
+function navigateToCategory(document, category) {
+ const navigation = document.querySelector("fxview-category-navigation");
+ let navButton = Array.from(navigation.categoryButtons).filter(
+ categoryButton => {
+ return categoryButton.name === category;
+ }
+ )[0];
+ navButton.buttonEl.click();
+}
+
+async function navigateToCategoryAndWait(document, category) {
+ info(`navigateToCategoryAndWait, for ${category}`);
+ const navigation = document.querySelector("fxview-category-navigation");
+ const win = document.ownerGlobal;
+ SimpleTest.promiseFocus(win);
+ let navButton = Array.from(navigation.categoryButtons).find(
+ categoryButton => {
+ return categoryButton.name === category;
+ }
+ );
+ const namedDeck = document.querySelector("named-deck");
+
+ await BrowserTestUtils.waitForCondition(
+ () => navButton.getBoundingClientRect().height,
+ `Waiting for ${category} button to be clickable`
+ );
+
+ EventUtils.synthesizeMouseAtCenter(navButton, {}, win);
+
+ await BrowserTestUtils.waitForCondition(() => {
+ let selectedView = Array.from(namedDeck.children).find(
+ child => child.slot == "selected"
+ );
+ return (
+ namedDeck.selectedViewName == category &&
+ selectedView?.getBoundingClientRect().height
+ );
+ }, `Waiting for ${category} to be visible`);
+}
+
+/**
+ * Switch to the Firefox View tab.
+ *
+ * @param {Window} [win]
+ * The window to use, if specified. Defaults to the global window instance.
+ * @returns {Promise<MozTabbrowserTab>}
+ * The tab switched to.
+ */
+async function switchToFxViewTab(win = window) {
+ return BrowserTestUtils.switchTab(win.gBrowser, win.FirefoxViewHandler.tab);
+}
+
+function isElInViewport(element) {
+ const boundingRect = element.getBoundingClientRect();
+ return (
+ boundingRect.top >= 0 &&
+ boundingRect.left >= 0 &&
+ boundingRect.bottom <=
+ (window.innerHeight || document.documentElement.clientHeight) &&
+ boundingRect.right <=
+ (window.innerWidth || document.documentElement.clientWidth)
+ );
+}
+
+// TODO once we port over old tests, helpers and cleanup old firefox view
+// we should decide whether to keep this or openFirefoxViewTab.
+async function clickFirefoxViewButton(win) {
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#firefox-view-button",
+ { type: "mousedown" },
+ win.browsingContext
+ );
+}
+
+/**
+ * Wait for and assert telemetry events.
+ *
+ * @param {Array} eventDetails
+ * Nested array of event details
+ */
+async function telemetryEvent(eventDetails) {
+ await TestUtils.waitForCondition(
+ () => {
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ false
+ ).parent;
+ return events && events.length >= 1;
+ },
+ "Waiting for firefoxview_next telemetry event.",
+ 200,
+ 100
+ );
+
+ TelemetryTestUtils.assertEvents(
+ eventDetails,
+ { category: "firefoxview_next" },
+ { clear: true, process: "parent" }
+ );
+}
+
+function setSortOption(component, value) {
+ info(`Sort by ${value}.`);
+ const el = component.optionsContainer.querySelector(
+ `input[value='${value}']`
+ );
+ EventUtils.synthesizeMouseAtCenter(el, {}, el.ownerGlobal);
+}
+
+function getOpenTabsCards(openTabs) {
+ return openTabs.shadowRoot.querySelectorAll("view-opentabs-card");
+}
+
+async function click_recently_closed_tab_item(itemElem, itemProperty = "") {
+ // Make sure the firefoxview tab still has focus
+ is(
+ itemElem.ownerDocument.location.href,
+ "about:firefoxview#recentlyclosed",
+ "about:firefoxview is the selected tab and showing the Recently closed view page"
+ );
+
+ // Scroll to the tab element to ensure dismiss button is visible
+ itemElem.scrollIntoView();
+ is(isElInViewport(itemElem), true, "Tab is visible in viewport");
+ let clickTarget;
+ switch (itemProperty) {
+ case "dismiss":
+ clickTarget = itemElem.buttonEl;
+ break;
+ default:
+ clickTarget = itemElem.mainEl;
+ break;
+ }
+
+ const closedObjectsChangePromise = TestUtils.topicObserved(
+ "sessionstore-closed-objects-changed"
+ );
+ EventUtils.synthesizeMouseAtCenter(clickTarget, {}, itemElem.ownerGlobal);
+ await closedObjectsChangePromise;
+}
+
+async function waitForRecentlyClosedTabsList(doc) {
+ let recentlyClosedComponent = doc.querySelector(
+ "view-recentlyclosed:not([slot=recentlyclosed])"
+ );
+ // Check that the tabs list is rendered
+ await TestUtils.waitForCondition(() => {
+ return recentlyClosedComponent.cardEl;
+ });
+ let cardContainer = recentlyClosedComponent.cardEl;
+ let cardMainSlotNode = Array.from(
+ cardContainer?.mainSlot?.assignedNodes()
+ )[0];
+ await TestUtils.waitForCondition(() => {
+ return cardMainSlotNode.rowEls.length;
+ });
+ return [cardMainSlotNode, cardMainSlotNode.rowEls];
+}
diff --git a/browser/components/firefoxview/tests/chrome/chrome.toml b/browser/components/firefoxview/tests/chrome/chrome.toml
new file mode 100644
index 0000000000..b1677430b2
--- /dev/null
+++ b/browser/components/firefoxview/tests/chrome/chrome.toml
@@ -0,0 +1,7 @@
+[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..c54a70faaf
--- /dev/null
+++ b/browser/components/firefoxview/tests/chrome/test_card_container.html
@@ -0,0 +1,122 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>CardContainer Tests</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script>
+ <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+ <link rel="stylesheet" href="chrome://global/skin/in-content/common.css">
+ <link rel="localization" href="browser/firefoxView.ftl"/>
+ <script type="module" src="chrome://browser/content/firefoxview/card-container.mjs"></script>
+</head>
+<body>
+ <style>
+ </style>
+<p id="display"></p>
+<div id="content">
+ <card-container shortPageName="history" showViewAll="true">
+ <h2 slot="header" data-l10n-id="history-header"></h2>
+ <ul slot="main">
+ <li>History Row 1</li>
+ <li>History Row 2</li>
+ <li>History Row 3</li>
+ <li>History Row 4</li>
+ <li>History Row 5</li>
+ </ul>
+ </card-container>
+</div>
+<pre id="test">
+<script class="testbody" type="application/javascript">
+ const cardContainer = document.querySelector("card-container");
+
+ /**
+ * Tests that the card-container can expand and collapse when the summary element is clicked
+ */
+ add_task(async function test_open_close_card() {
+ is(
+ cardContainer.isExpanded,
+ true,
+ "The card-container is expanded initially"
+ );
+
+ // Click the summary to collapse the details disclosure
+ cardContainer.summaryEl.click();
+ is(
+ cardContainer.detailsEl.hasAttribute("open"),
+ false,
+ "The card-container is collapsed"
+ );
+
+ // Click on the summary again to expand the details disclosure
+ cardContainer.summaryEl.click();
+ is(
+ cardContainer.detailsEl.hasAttribute("open"),
+ true,
+ "The card-container is expanded"
+ );
+ });
+
+ /**
+ * Tests keyboard navigation of the card-container component
+ */
+ add_task(async function test_keyboard_navigation() {
+ const tab = async shiftKey => {
+ info(`Tab${shiftKey ? ' + Shift' : ''}`);
+ synthesizeKey("KEY_Tab", { shiftKey });
+ };
+ const enter = async () => {
+ info("Enter");
+ synthesizeKey("KEY_Enter", {});
+ };
+
+ // Setting this pref allows the test to run as expected with a keyboard on MacOS
+ await SpecialPowers.pushPrefEnv({
+ set: [["accessibility.tabfocus", 7]],
+ });
+
+ cardContainer.summaryEl.focus();
+ is(
+ cardContainer.shadowRoot.activeElement,
+ cardContainer.summaryEl,
+ "Focus should be on the summary element within card-container"
+ );
+
+ // Tab to the 'View all' link
+ await tab();
+ is(
+ cardContainer.shadowRoot.activeElement,
+ cardContainer.viewAllLink,
+ "Focus should be on the 'View all' link within card-container"
+ );
+
+ // Shift + Tab back to the summary element
+ await tab(true);
+ is(
+ cardContainer.shadowRoot.activeElement,
+ cardContainer.summaryEl,
+ "Focus should be back on the summary element within card-container"
+ );
+
+ // Select the summary to collapse the details disclosure
+ await enter();
+ is(
+ cardContainer.detailsEl.hasAttribute("open"),
+ false,
+ "The card-container is collapsed"
+ );
+
+ // Select the summary again to expand the details disclosure
+ await enter();
+ is(
+ cardContainer.detailsEl.hasAttribute("open"),
+ true,
+ "The card-container is expanded"
+ );
+
+ await SpecialPowers.popPrefEnv();
+ });
+</script>
+</pre>
+</body>
+</html>
diff --git a/browser/components/firefoxview/tests/chrome/test_fxview_category_navigation.html b/browser/components/firefoxview/tests/chrome/test_fxview_category_navigation.html
new file mode 100644
index 0000000000..0ea0a94baf
--- /dev/null
+++ b/browser/components/firefoxview/tests/chrome/test_fxview_category_navigation.html
@@ -0,0 +1,322 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>FxviewCategoryNavigation Tests</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script>
+ <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+ <link rel="stylesheet" href="chrome://global/skin/in-content/common.css">
+ <script type="module" src="chrome://browser/content/firefoxview/fxview-category-navigation.mjs"></script>
+</head>
+<style>
+body {
+ display: flex;
+}
+#navigation {
+ width: var(--in-content-sidebar-width);
+}
+fxview-category-button[name="category-one"]::part(icon) {
+ background-image: url("chrome://mozapps/skin/extensions/category-discover.svg");
+}
+fxview-category-button[name="category-two"]::part(icon) {
+ background-image: url("chrome://mozapps/skin/extensions/category-discover.svg");
+}
+fxview-category-button[name="category-three"]::part(icon) {
+ background-image: url("chrome://mozapps/skin/extensions/category-discover.svg");
+}
+fxview-category-button[name="category-four"]::part(icon) {
+ background-image: url("chrome://mozapps/skin/extensions/category-discover.svg");
+}
+fxview-category-button[name="category-five"]::part(icon) {
+ background-image: url("chrome://mozapps/skin/extensions/category-discover.svg");
+}
+</style>
+<body>
+ <p id="display"></p>
+ <div id="content">
+ <div id="navigation">
+ <fxview-category-navigation>
+ <h2 slot="category-nav-header">Header</h2>
+ <fxview-category-button class="category" slot="category-button" name="category-one">
+ <span class="category-name">Category 1</span>
+ </fxview-category-button>
+ <fxview-category-button class="category" slot="category-button" name="category-two">
+ <span class="category-name">Category 2</span>
+ </fxview-category-button>
+ <fxview-category-button class="category" slot="category-button" name="category-three">
+ <span class="category-name">Category 3</span>
+ </fxview-category-button>
+ <fxview-category-button class="category" slot="category-button" name="category-four">
+ <span class="category-name">Category 4</span>
+ </fxview-category-button>
+ <fxview-category-button class="category" slot="category-button" name="category-five">
+ <span class="category-name">Category 5</span>
+ </fxview-category-button>
+ </fxview-category-navigation>
+ </div>
+ </div>
+<pre id="test"></pre>
+<script>
+ Services.scriptloader.loadSubScript(
+ "chrome://browser/content/utilityOverlay.js",
+ this
+ );
+ const { BrowserTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/BrowserTestUtils.sys.mjs"
+ );
+
+const fxviewCategoryNav = document.querySelector("fxview-category-navigation");
+
+function isActiveElement(expectedActiveEl) {
+ return expectedActiveEl.getRootNode().activeElement == expectedActiveEl;
+ }
+
+ /**
+ * Tests that the first category is selected by default
+ */
+ add_task(async function test_first_item_selected_by_default() {
+ is(
+ fxviewCategoryNav.categoryButtons.length,
+ 5,
+ "Five category buttons are in the navigation"
+ );
+
+ ok(
+ fxviewCategoryNav.categoryButtons[0].name === fxviewCategoryNav.currentCategory,
+ "The first category button is selected by default"
+ )
+ });
+
+ /**
+ * Tests that categories are selected when clicked
+ */
+ add_task(async function test_select_category() {
+ let gBrowser = BrowserWindowTracker.getTopWindow().top.gBrowser;
+ let secondCategory = fxviewCategoryNav.categoryButtons[1];
+ let categoryChanged = BrowserTestUtils.waitForEvent(
+ gBrowser,
+ "change-category"
+ );
+
+ secondCategory.buttonEl.click();
+ await categoryChanged;
+
+ ok(
+ secondCategory.name === fxviewCategoryNav.currentCategory,
+ "The second category button is selected"
+ )
+
+ let thirdCategory = fxviewCategoryNav.categoryButtons[2];
+ categoryChanged = BrowserTestUtils.waitForEvent(
+ gBrowser,
+ "change-category"
+ );
+
+ thirdCategory.buttonEl.click();
+ await categoryChanged;
+
+ ok(
+ thirdCategory.name === fxviewCategoryNav.currentCategory,
+ "The third category button is selected"
+ )
+
+ let firstCategory = fxviewCategoryNav.categoryButtons[0];
+ categoryChanged = BrowserTestUtils.waitForEvent(
+ gBrowser,
+ "change-category"
+ );
+
+ firstCategory.buttonEl.click();
+ await categoryChanged;
+
+ ok(
+ firstCategory.name === fxviewCategoryNav.currentCategory,
+ "The first category button is selected"
+ )
+ });
+
+ /**
+ * Tests that categories are keyboard-navigable
+ */
+ add_task(async function test_keyboard_navigation() {
+ const arrowDown = async () => {
+ info("Arrow down");
+ synthesizeKey("KEY_ArrowDown", {});
+ await fxviewCategoryNav.getUpdateComplete();
+ };
+ const arrowUp = async () => {
+ info("Arrow up");
+ synthesizeKey("KEY_ArrowUp", {});
+ await fxviewCategoryNav.getUpdateComplete();
+ };
+ const arrowLeft = async () => {
+ info("Arrow left");
+ synthesizeKey("KEY_ArrowLeft", {});
+ await fxviewCategoryNav.getUpdateComplete();
+ };
+ const arrowRight = async () => {
+ info("Arrow right");
+ synthesizeKey("KEY_ArrowRight", {});
+ await fxviewCategoryNav.getUpdateComplete();
+ };
+
+ // Setting this pref allows the test to run as expected with a keyboard on MacOS
+ await SpecialPowers.pushPrefEnv({
+ set: [["accessibility.tabfocus", 7]],
+ });
+
+ let firstCategory = fxviewCategoryNav.categoryButtons[0];
+ let secondCategory = fxviewCategoryNav.categoryButtons[1];
+ let thirdCategory = fxviewCategoryNav.categoryButtons[2];
+ let fourthCategory = fxviewCategoryNav.categoryButtons[3];
+ let fifthCategory = fxviewCategoryNav.categoryButtons[4];
+
+ is(
+ firstCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The first category button is selected"
+ )
+ firstCategory.focus();
+ await arrowDown();
+ ok(
+ isActiveElement(secondCategory),
+ "The second category button is the active element after first arrow down"
+ );
+ is(
+ secondCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The second category button is selected"
+ )
+ await arrowDown();
+ is(
+ thirdCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The third category button is selected"
+ )
+ await arrowDown();
+ is(
+ fourthCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The fourth category button is selected"
+ )
+ await arrowDown();
+ is(
+ fifthCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The fifth category button is selected"
+ )
+ await arrowDown();
+ is(
+ fifthCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The fifth category button is still selected"
+ )
+ await arrowUp();
+ is(
+ fourthCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The fourth category button is selected"
+ )
+ await arrowUp();
+ is(
+ thirdCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The third category button is selected"
+ )
+ await arrowUp();
+ is(
+ secondCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The second category button is selected"
+ )
+ await arrowUp();
+ is(
+ firstCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The first category button is selected"
+ )
+ await arrowUp();
+ is(
+ firstCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The first category button is still selected"
+ )
+
+ // Test navigation with arrow left/right keys
+ is(
+ firstCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The first category button is selected"
+ )
+ firstCategory.focus();
+ await arrowRight();
+ ok(
+ isActiveElement(secondCategory),
+ "The second category button is the active element after first arrow right"
+ );
+ is(
+ secondCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The second category button is selected"
+ )
+ await arrowRight();
+ is(
+ thirdCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The third category button is selected"
+ )
+ await arrowRight();
+ is(
+ fourthCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The fourth category button is selected"
+ )
+ await arrowRight();
+ is(
+ fifthCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The fifth category button is selected"
+ )
+ await arrowRight();
+ is(
+ fifthCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The fifth category button is still selected"
+ )
+ await arrowLeft();
+ is(
+ fourthCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The fourth category button is selected"
+ )
+ await arrowLeft();
+ is(
+ thirdCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The third category button is selected"
+ )
+ await arrowLeft();
+ is(
+ secondCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The second category button is selected"
+ )
+ await arrowLeft();
+ is(
+ firstCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The first category button is selected"
+ )
+ await arrowLeft();
+ is(
+ firstCategory.name,
+ fxviewCategoryNav.currentCategory,
+ "The first category button is still selected"
+ )
+
+ await SpecialPowers.popPrefEnv();
+ });
+</script>
+</body>
+</html>
diff --git a/browser/components/firefoxview/tests/chrome/test_fxview_tab_list.html b/browser/components/firefoxview/tests/chrome/test_fxview_tab_list.html
new file mode 100644
index 0000000000..22f04acab2
--- /dev/null
+++ b/browser/components/firefoxview/tests/chrome/test_fxview_tab_list.html
@@ -0,0 +1,447 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>FxviewTabList Tests</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script>
+ <link rel="localization" href="browser/places.ftl">
+ <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+ <link rel="stylesheet" href="chrome://global/skin/in-content/common.css">
+ <script type="module" src="chrome://browser/content/firefoxview/fxview-tab-list.mjs"></script>
+</head>
+<body>
+ <style>
+ fxview-tab-list.history::part(secondary-button) {
+ background-image: url("chrome://global/skin/icons/more.svg");
+ }
+ </style>
+<p id="display"></p>
+<div id="content" style="max-width: 750px">
+ <fxview-tab-list class="history" .dateTimeFormat="relative" .hasPopup="menu">
+ <panel-list slot="menu">
+ <panel-item data-l10n-id="fxviewtabrow-delete"></panel-item>
+ <panel-item data-l10n-id="fxviewtabrow-forget-about-this-site"></panel-item>
+ <hr />
+ <panel-item data-l10n-id="fxviewtabrow-open-in-window"></panel-item>
+ <panel-item data-l10n-id="fxviewtabrow-open-in-private-window"></panel-item>
+ <hr />
+ <panel-item data-l10n-id="fxviewtabrow-add-bookmark"></panel-item>
+ <panel-item data-l10n-id="fxviewtabrow-save-to-pocket"></panel-item>
+ <panel-item data-l10n-id="fxviewtabrow-copy-link"></panel-item>
+ </panel-list>
+ </fxview-tab-list>
+</div>
+<pre id="test">
+<script class="testbody" type="application/javascript">
+ Services.scriptloader.loadSubScript(
+ "chrome://browser/content/utilityOverlay.js",
+ this
+ );
+
+ const { BrowserTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/BrowserTestUtils.sys.mjs"
+ );
+ const { FirefoxViewPlacesQuery } = ChromeUtils.importESModule(
+ "resource:///modules/firefox-view-places-query.sys.mjs"
+ );
+ const { PlacesUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PlacesUtils.sys.mjs"
+ );
+ const { PlacesUIUtils } = ChromeUtils.importESModule(
+ "resource:///modules/PlacesUIUtils.sys.mjs"
+ );
+ const { PlacesTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PlacesTestUtils.sys.mjs"
+ );
+
+ const fxviewTabList = document.querySelector("fxview-tab-list");
+ let tabItems = [];
+ const placesQuery = new FirefoxViewPlacesQuery();
+
+ const URLs = [
+ "http://mochi.test:8888/browser/",
+ "https://www.example.com/",
+ "https://example.net/",
+ "https://example.org/",
+ "https://www.mozilla.org/"
+ ];
+
+ async function addHistoryItems() {
+ await PlacesUtils.history.clear();
+ let history = await placesQuery.getHistory();
+
+ const now = new Date();
+ await PlacesUtils.history.insert({
+ url: URLs[0],
+ title: "Example Domain 1",
+ visits: [{ date: now }],
+ });
+ let historyUpdated = Promise.withResolvers();
+ placesQuery.observeHistory(newHistory => {
+ history = newHistory;
+ historyUpdated.resolve();
+ });
+ await PlacesUtils.history.insert({
+ url: URLs[1],
+ title: "Example Domain 2",
+ visits: [{ date: now }],
+ });
+ await historyUpdated.promise;
+ historyUpdated = Promise.withResolvers();
+ placesQuery.observeHistory(newHistory => {
+ history = newHistory;
+ historyUpdated.resolve();
+ });
+ await PlacesUtils.history.insert({
+ url: URLs[2],
+ title: "Example Domain 3",
+ visits: [{ date: now }],
+ });
+ await historyUpdated.promise;
+ historyUpdated = Promise.withResolvers();
+ placesQuery.observeHistory(newHistory => {
+ history = newHistory;
+ historyUpdated.resolve();
+ });
+ await PlacesUtils.history.insert({
+ url: URLs[3],
+ title: "Example Domain 4",
+ visits: [{ date: now }],
+ });
+ await historyUpdated.promise;
+
+ fxviewTabList.tabItems = [...history.values()].flat();
+
+ await fxviewTabList.getUpdateComplete();
+ tabItems = Array.from(fxviewTabList.rowEls);
+ }
+
+ function getCurrentDisplayDate() {
+ let lastItemMainEl = tabItems[tabItems.length - 1].mainEl;
+ return lastItemMainEl.querySelector("#fxview-tab-row-date span:not([hidden])")?.textContent.trim() ?? "";
+ }
+
+ function getCurrentDisplayTime() {
+ let lastItemMainEl = tabItems[tabItems.length - 1].mainEl;
+ return lastItemMainEl.querySelector("#fxview-tab-row-time")?.textContent.trim() ?? "";
+ }
+
+ function isActiveElement(expectedLinkEl) {
+ return expectedLinkEl.getRootNode().activeElement == expectedLinkEl;
+ }
+
+ function onPrimaryAction(e) {
+ let gBrowser = BrowserWindowTracker.getTopWindow().top.gBrowser;
+ gBrowser.addTrustedTab(e.originalTarget.url);
+ }
+
+ function onSecondaryAction(e) {
+ e.target.querySelector("panel-list").toggle(e.detail.originalEvent);
+ }
+
+ add_setup(function setup() {
+ fxviewTabList.addEventListener("fxview-tab-list-primary-action", onPrimaryAction);
+ fxviewTabList.addEventListener("fxview-tab-list-secondary-action", onSecondaryAction);
+ fxviewTabList.updatesPaused = false;
+ });
+
+ /**
+ * Tests that history items are loaded in the expected order
+ */
+ add_task(async function test_list_ordering() {
+ await addHistoryItems();
+ is(
+ tabItems.length,
+ 4,
+ "Four history items are shown in the list."
+ );
+
+ // Check ordering
+ ok(
+ tabItems[0].title === "Example Domain 4",
+ "First history item in fxview-tab-list is in the correct order."
+ )
+
+ ok(
+ tabItems[3].title === "Example Domain 1",
+ "Last history item in fxview-tab-list is in the correct order."
+ )
+ });
+
+ /**
+ * Tests the primary action function is triggered when selecting the main row element
+ */
+ add_task(async function test_primary_action(){
+ await addHistoryItems();
+ let gBrowser = BrowserWindowTracker.getTopWindow().top.gBrowser;
+ let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, tabItems[0].url);
+ tabItems[0].mainEl.click();
+ await newTabPromise;
+
+ is(
+ tabItems.length,
+ 4,
+ "Four history items are still shown in the list."
+ );
+
+ await BrowserTestUtils.removeTab(gBrowser.tabs[gBrowser.tabs.length - 1]);
+ });
+
+ /**
+ * Tests that a max tabs length value can be given to fxview-tab-list
+ */
+ add_task(async function test_max_list_items() {
+ const mockMaxTabsLength = 3;
+
+ // override this value for testing purposes
+ fxviewTabList.maxTabsLength = mockMaxTabsLength;
+ await addHistoryItems();
+
+ is(
+ tabItems.length,
+ mockMaxTabsLength,
+ `fxview-tabs-list should have ${mockMaxTabsLength} list items`
+ );
+
+ // Add new history items
+ let history = await placesQuery.getHistory();
+
+ const now = new Date();
+ await PlacesUtils.history.insert({
+ url: URLs[4],
+ title: "Internet for people, not profits - Mozilla",
+ visits: [{ date: now }],
+ });
+ let historyUpdated = Promise.withResolvers();
+ placesQuery.observeHistory(newHistory => {
+ history = newHistory;
+ historyUpdated.resolve();
+ });
+ await historyUpdated.promise;
+
+ ok(
+ [...history.values()].reduce((acc, {length}) => acc + length, 0) === 5,
+ "Five total history items after inserting another node"
+ );
+
+ // Update fxview-tab-list component with latest history data
+ fxviewTabList.tabItems = [...history.values()].flat();
+ await fxviewTabList.getUpdateComplete();
+ tabItems = Array.from(fxviewTabList.rowEls);
+
+ is(
+ tabItems.length,
+ mockMaxTabsLength,
+ `fxview-tabs-list should have ${mockMaxTabsLength} list items`
+ );
+
+ ok(
+ tabItems[0].title === "Internet for people, not profits - Mozilla",
+ "History list has been updated with the expected maxTabsLength."
+ )
+ fxviewTabList.maxTabsLength = 25;
+ });
+
+ /**
+ * Tests keyboard navigation of the fxview-tab-list component
+ */
+ add_task(async function test_keyboard_navigation() {
+ const arrowDown = async () => {
+ info("Arrow down");
+ synthesizeKey("KEY_ArrowDown", {});
+ await fxviewTabList.getUpdateComplete();
+ };
+ const arrowUp = async () => {
+ info("Arrow up");
+ synthesizeKey("KEY_ArrowUp", {});
+ await fxviewTabList.getUpdateComplete();
+ };
+ const arrowRight = async () => {
+ info("Arrow right");
+ synthesizeKey("KEY_ArrowRight", {});
+ await fxviewTabList.getUpdateComplete();
+ };
+ const arrowLeft = async () => {
+ info("Arrow left");
+ synthesizeKey("KEY_ArrowLeft", {});
+ await fxviewTabList.getUpdateComplete();
+ };
+
+ await addHistoryItems();
+ tabItems[0].mainEl.focus();
+ ok(
+ isActiveElement(tabItems[0].mainEl),
+ "Focus should be on the first main element of the list"
+ );
+
+ // Arrow down/up the list
+ await arrowDown();
+ ok(
+ isActiveElement(tabItems[1].mainEl),
+ "Focus should be on the second main element of the list"
+ );
+ await arrowDown();
+ ok(
+ isActiveElement(tabItems[2].mainEl),
+ "Focus should be on the third main element of the list"
+ );
+ await arrowDown();
+ ok(
+ isActiveElement(tabItems[3].mainEl),
+ "Focus should be on the fourth main element of the list"
+ );
+ await arrowUp();
+ ok(
+ isActiveElement(tabItems[2].mainEl),
+ "Focus should be on the third main element of the list"
+ );
+ await arrowUp();
+ ok(
+ isActiveElement(tabItems[1].mainEl),
+ "Focus should be on the second main element of the list"
+ );
+ await arrowUp();
+ ok(
+ isActiveElement(tabItems[0].mainEl),
+ "Focus should be on the first main element of the list"
+ );
+ await arrowRight();
+ ok(
+ isActiveElement(tabItems[0].buttonEl),
+ "Focus should be on the first row's context menu button element of the list"
+ );
+ await arrowDown();
+ ok(
+ isActiveElement(tabItems[1].buttonEl),
+ "Focus should be on the second row's context menu button element of the list"
+ );
+ await arrowLeft();
+ ok(
+ isActiveElement(tabItems[1].mainEl),
+ "Focus should be on the second main element of the list"
+ );
+ await arrowUp();
+ ok(
+ isActiveElement(tabItems[0].mainEl),
+ "Focus should be on the first main element of the list"
+ );
+ });
+
+ /**
+ * Tests relative time format for the fxview-tab-list component
+ */
+ add_task(async function test_relative_format() {
+ await addHistoryItems();
+ ok(
+ getCurrentDisplayDate().includes("Just now"),
+ "Current dateTime format is 'relative' and date displays 'Just now' initially"
+ );
+ ok(
+ !getCurrentDisplayTime().length,
+ "Current dateTime format is 'relative' and time displays an empty string"
+ );
+ });
+
+ /**
+ * Tests date only format for the fxview-tab-list component
+ */
+ add_task(async function test_date_only_format() {
+ await addHistoryItems();
+
+ // Check date only format
+ fxviewTabList.dateTimeFormat = "date";
+ await fxviewTabList.getUpdateComplete();
+ await BrowserTestUtils.waitForCondition(() => {
+ return getCurrentDisplayDate().includes("/");
+ });
+ ok(
+ getCurrentDisplayDate().includes("/"),
+ "Current dateTime format is 'date' and displays the current date"
+ );
+ ok(
+ !getCurrentDisplayTime().length,
+ "Current dateTime format is 'date' and time displays an empty string"
+ );
+ });
+
+ /**
+ * Tests time only format for the fxview-tab-list component
+ */
+ add_task(async function test_time_only_format() {
+ await addHistoryItems();
+
+ // Check time only format
+ fxviewTabList.dateTimeFormat = "time";
+ await fxviewTabList.getUpdateComplete();
+ await BrowserTestUtils.waitForCondition(() => {
+ return getCurrentDisplayTime().includes("AM") || getCurrentDisplayTime().includes("PM");
+ });
+ ok(
+ !getCurrentDisplayDate().length,
+ "Current dateTime format is 'time' and date displays an empty string"
+ );
+ ok(
+ getCurrentDisplayTime().includes("AM") || getCurrentDisplayTime().includes("PM"),
+ "Current dateTime format is 'time' and displays the current time"
+ );
+ });
+
+ /**
+ * Tests date and time format for the fxview-tab-list component
+ */
+ add_task(async function test_date_and_time_format() {
+ await addHistoryItems();
+
+ // Check date and time format
+ fxviewTabList.dateTimeFormat = "dateTime";
+ await fxviewTabList.getUpdateComplete();
+ await BrowserTestUtils.waitForCondition(() => {
+ return getCurrentDisplayDate().includes("/") &&
+ (getCurrentDisplayTime().includes("AM") || getCurrentDisplayTime().includes("PM"));
+ });
+ ok(
+ getCurrentDisplayDate().includes("/"),
+ "Current dateTime format is 'dateTime' and date displays the current date"
+ );
+ ok(
+ getCurrentDisplayTime().includes("AM") || getCurrentDisplayTime().includes("PM"),
+ "Current dateTime format is 'dateTime' and displays the current time"
+ );
+
+ // Reset dateTimeFormat to relative before next test
+ fxviewTabList.dateTimeFormat = "relative";
+ await fxviewTabList.getUpdateComplete();
+ });
+
+ /**
+ * Tests that relative time updates properly for the fxview-tab-list component
+ */
+ add_task(async function test_relative_time_updates() {
+ await addHistoryItems();
+
+ await BrowserTestUtils.waitForCondition(() => {
+ return getCurrentDisplayDate().includes("Just now");
+ });
+
+ ok(
+ getCurrentDisplayDate().includes("Just now"),
+ "Current date element displays 'Just now' initially"
+ );
+
+ // Set the updateTimeMs pref to something low to check that relative time updates properly
+ const TAB_UPDATE_TIME_MS = 500;
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.tabs.firefox-view.updateTimeMs", TAB_UPDATE_TIME_MS]],
+ });
+ await BrowserTestUtils.waitForCondition(() => {
+ return !getCurrentDisplayDate().includes("now");
+ });
+ info("Currently displayed date is something other than 'Just now'");
+
+ await SpecialPowers.popPrefEnv();
+ });
+</script>
+</pre>
+</body>
+</html>
diff --git a/browser/components/firefoxview/triage.json b/browser/components/firefoxview/triage.json
new file mode 100644
index 0000000000..f740325061
--- /dev/null
+++ b/browser/components/firefoxview/triage.json
@@ -0,0 +1,31 @@
+{
+ "triagers": {
+ "Jonathan Sudiaman": {
+ "bzmail": "jsudiaman@mozilla.com"
+ },
+ "Kelly Cochrane": {
+ "bzmail": "kcochrane@mozilla.com"
+ },
+ "Nikki Sharpley": {
+ "bzmail": "nsharpley@mozilla.com"
+ },
+ "Sam Foster": {
+ "bzmail": "sfoster@mozilla.com"
+ },
+ "Sarah Clements": {
+ "bzmail": "sclements@mozilla.com"
+ }
+ },
+ "duty-start-dates": {
+ "2023-12-01": "Jonathan Sudiaman",
+ "2024-03-15": "Kelly Cochrane",
+ "2024-07-01": "Sarah Clements",
+ "2024-10-01": "Nikki Sharpley",
+ "2025-01-01": "Sam Foster",
+ "2025-04-01": "Jonathan Sudiaman",
+ "2025-07-01": "Kelly Cochrane",
+ "2025-10-01": "Sarah Clements",
+ "2026-01-01": "Nikki Sharpley",
+ "2026-03-01": "Sam Foster"
+ }
+}
diff --git a/browser/components/firefoxview/view-opentabs.css b/browser/components/firefoxview/view-opentabs.css
new file mode 100644
index 0000000000..c1d1a320f8
--- /dev/null
+++ b/browser/components/firefoxview/view-opentabs.css
@@ -0,0 +1,44 @@
+/* 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/. */
+
+.view-opentabs-card-container {
+ display: grid;
+ gap: 1em;
+}
+
+[card-count="one"] {
+ grid-template-columns: 1fr;
+}
+
+[card-count="two"] {
+ grid-template-columns: repeat(2, 1fr);
+}
+
+[card-count="three-or-more"] {
+ grid-template-columns: repeat(3, 1fr);
+
+ @media (max-width: 85rem) {
+ /* Switch to 2-column layout on narrow viewports */
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+.open-tabs-options, .open-tabs-sort-wrapper {
+ display: flex;
+ gap: 24px;
+}
+
+.open-tabs-options {
+ flex-wrap: wrap;
+}
+
+.open-tabs-sort-option {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+
+ & label {
+ white-space: nowrap;
+ }
+}
diff --git a/browser/components/firefoxview/view-syncedtabs.css b/browser/components/firefoxview/view-syncedtabs.css
new file mode 100644
index 0000000000..990a40408c
--- /dev/null
+++ b/browser/components/firefoxview/view-syncedtabs.css
@@ -0,0 +1,118 @@
+/* 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");
+
+.icon {
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ background-position: center center;
+ background-repeat: no-repeat;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+.phone,
+.mobile {
+ 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');
+}
+
+h2 {
+ display: flex;
+ align-items: center;
+}
+
+h3.device-header {
+ display: grid;
+ align-items: center;
+ cursor: inherit;
+ font-weight: var(--fxview-card-header-font-weight);
+ font-size: 1em;
+ grid-template-columns: min-content 1fr;
+ gap: 0 16px;
+ margin: 0;
+}
+
+h3.device-header:not([slot="header"]) {
+ margin-block: 0.7em;
+ margin-inline: 0.5em 0;
+}
+
+h3.device-header:not([slot="header"]):not(:first-child) {
+ margin-block-start: 1.6em;
+}
+
+.notabs {
+ margin-block-start: 1em;
+ color: var(--fxview-text-secondary-color);
+}
+
+.blackbox {
+ border: 1px solid var(--fxview-border);
+ text-align: center;
+ height: 70px;
+ border-radius: 8px;
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.search-results-empty {
+ height: unset;
+ min-height: 70px;
+ overflow-wrap: anywhere;
+}
+
+button.primary {
+ white-space: nowrap;
+ min-width: fit-content;
+}
+
+label {
+ display: flex;
+ align-items: center;
+}
+
+.syncedtabs-header {
+ display: flex;
+ justify-content: space-between;
+ height: 34px;
+ align-items: center;
+}
+
+.syncedtabs-header button {
+ display: flex;
+ align-items: center;
+ min-width: unset;
+ margin-inline-start: auto;
+}
+
+.syncedtabs-header button .icon {
+ margin-inline-end: 0.7rem;
+}
+
+.show-all-link-container {
+ display: flex;
+ justify-content: center;
+ color: var(--fxview-primary-action-background);
+ cursor: pointer;
+}
+
+.show-all-link {
+ text-decoration: underline;
+ display: inline-block;
+ outline-offset: 2px;
+ border-radius: 2px;
+ margin-block: 0.5rem;
+}
diff --git a/browser/components/firefoxview/viewpage.mjs b/browser/components/firefoxview/viewpage.mjs
new file mode 100644
index 0000000000..fee02b49d6
--- /dev/null
+++ b/browser/components/firefoxview/viewpage.mjs
@@ -0,0 +1,261 @@
+/* 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";
+
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://browser/content/firefoxview/card-container.mjs";
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://browser/content/firefoxview/fxview-empty-state.mjs";
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://browser/content/firefoxview/fxview-search-textbox.mjs";
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://browser/content/firefoxview/fxview-tab-list.mjs";
+
+import { placeLinkOnClipboard } from "./helpers.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
+});
+
+const WIN_RESIZE_DEBOUNCE_RATE_MS = 500;
+const WIN_RESIZE_DEBOUNCE_TIMEOUT_MS = 1000;
+
+/**
+ * A base class for content container views displayed on firefox-view.
+ *
+ * @property {boolean} recentBrowsing
+ * Is part of the recentbrowsing page view
+ * @property {boolean} paused
+ * No content will be updated and rendered while paused
+ */
+export class ViewPageContent extends MozLitElement {
+ static get properties() {
+ return {
+ recentBrowsing: { type: Boolean },
+ paused: { type: Boolean },
+ };
+ }
+ constructor() {
+ super();
+ // don't update or render until explicitly un-paused
+ this.paused = true;
+ }
+
+ get ownerViewPage() {
+ return this.closest("[type='page']") || this;
+ }
+
+ get isVisible() {
+ if (!this.isConnected || this.ownerDocument.visibilityState != "visible") {
+ return false;
+ }
+ return this.ownerViewPage.selectedTab;
+ }
+
+ /**
+ * Override this function to run a callback whenever this content is visible.
+ */
+ viewVisibleCallback() {}
+
+ /**
+ * Override this function to run a callback whenever this content is hidden.
+ */
+ viewHiddenCallback() {}
+
+ getWindow() {
+ return window.browsingContext.embedderWindowGlobal.browsingContext.window;
+ }
+
+ get isSelectedBrowserTab() {
+ const { gBrowser } = this.getWindow();
+ return gBrowser.selectedBrowser.browsingContext == window.browsingContext;
+ }
+
+ copyLink(e) {
+ placeLinkOnClipboard(this.triggerNode.title, this.triggerNode.url);
+ this.recordContextMenuTelemetry("copy-link", e);
+ }
+
+ openInNewWindow(e) {
+ this.getWindow().openTrustedLinkIn(this.triggerNode.url, "window", {
+ private: false,
+ });
+ this.recordContextMenuTelemetry("open-in-new-window", e);
+ }
+
+ openInNewPrivateWindow(e) {
+ this.getWindow().openTrustedLinkIn(this.triggerNode.url, "window", {
+ private: true,
+ });
+ this.recordContextMenuTelemetry("open-in-private-window", e);
+ }
+
+ recordContextMenuTelemetry(menuAction, event) {
+ Services.telemetry.recordEvent(
+ "firefoxview_next",
+ "context_menu",
+ "tabs",
+ null,
+ {
+ menu_action: menuAction,
+ data_type: event.target.panel.dataset.tabType,
+ }
+ );
+ }
+
+ shouldUpdate(changedProperties) {
+ return !this.paused && super.shouldUpdate(changedProperties);
+ }
+}
+
+/**
+ * A "page" in firefox view, which may be hidden or shown by the named-deck container or
+ * via the owner document's visibility
+ *
+ * @property {boolean} selectedTab
+ * Is this page the selected view in the named-deck container
+ */
+export class ViewPage extends ViewPageContent {
+ static get properties() {
+ return {
+ selectedTab: { type: Boolean },
+ searchTextboxSize: { type: Number },
+ };
+ }
+
+ constructor() {
+ super();
+ this.selectedTab = false;
+ this.recentBrowsing = Boolean(this.recentBrowsingElement);
+ this.onVisibilityChange = this.onVisibilityChange.bind(this);
+ this.onResize = this.onResize.bind(this);
+ }
+
+ get recentBrowsingElement() {
+ return this.closest("VIEW-RECENTBROWSING");
+ }
+
+ onResize() {
+ this.windowResizeTask = new lazy.DeferredTask(
+ () => this.updateAllVirtualLists(),
+ WIN_RESIZE_DEBOUNCE_RATE_MS,
+ WIN_RESIZE_DEBOUNCE_TIMEOUT_MS
+ );
+ this.windowResizeTask?.arm();
+ }
+
+ onVisibilityChange(event) {
+ if (this.isVisible) {
+ this.paused = false;
+ this.viewVisibleCallback();
+ } else if (
+ this.ownerViewPage.selectedTab &&
+ this.ownerDocument.visibilityState == "hidden"
+ ) {
+ this.paused = true;
+ this.viewHiddenCallback();
+ }
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.ownerDocument.addEventListener(
+ "visibilitychange",
+ this.onVisibilityChange
+ );
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this.ownerDocument.removeEventListener(
+ "visibilitychange",
+ this.onVisibilityChange
+ );
+ this.getWindow().removeEventListener("resize", this.onResize);
+ }
+
+ updateAllVirtualLists() {
+ if (!this.paused) {
+ let tabLists = [];
+ if (this.recentBrowsing) {
+ let viewComponents = this.querySelectorAll("[slot]");
+ viewComponents.forEach(viewComponent => {
+ let currentTabLists = [];
+ if (viewComponent.nodeName.includes("OPENTABS")) {
+ viewComponent.viewCards.forEach(viewCard => {
+ currentTabLists.push(viewCard.tabList);
+ });
+ } else {
+ currentTabLists =
+ viewComponent.shadowRoot.querySelectorAll("fxview-tab-list");
+ }
+ tabLists.push(...currentTabLists);
+ });
+ } else {
+ tabLists = this.shadowRoot.querySelectorAll("fxview-tab-list");
+ }
+ tabLists.forEach(tabList => {
+ if (!tabList.updatesPaused && tabList.rootVirtualListEl?.isVisible) {
+ tabList.rootVirtualListEl.recalculateAfterWindowResize();
+ }
+ });
+ }
+ }
+
+ toggleVisibilityInCardContainer(isOpenTabs) {
+ let cards = [];
+ let tabLists = [];
+ if (!isOpenTabs) {
+ cards = this.shadowRoot.querySelectorAll("card-container");
+ tabLists = this.shadowRoot.querySelectorAll("fxview-tab-list");
+ } else {
+ this.viewCards.forEach(viewCard => {
+ if (viewCard.cardEl) {
+ cards.push(viewCard.cardEl);
+ tabLists.push(viewCard.tabList);
+ }
+ });
+ }
+ if (tabLists.length && cards.length) {
+ cards.forEach(cardEl => {
+ if (cardEl.visible !== !this.paused) {
+ cardEl.visible = !this.paused;
+ } else if (
+ cardEl.isExpanded &&
+ Array.from(tabLists).some(
+ tabList => tabList.updatesPaused !== this.paused
+ )
+ ) {
+ // If card is already visible and expanded but tab-list has updatesPaused,
+ // update the tab-list updatesPaused prop from here instead of card-container
+ tabLists.forEach(tabList => {
+ tabList.updatesPaused = this.paused;
+ });
+ }
+ });
+ }
+ }
+
+ enter() {
+ this.selectedTab = true;
+ if (this.isVisible) {
+ this.paused = false;
+ this.viewVisibleCallback();
+ this.getWindow().addEventListener("resize", this.onResize);
+ }
+ }
+
+ exit() {
+ this.selectedTab = false;
+ this.paused = true;
+ this.viewHiddenCallback();
+ if (!this.windowResizeTask?.isFinalized) {
+ this.windowResizeTask?.finalize();
+ }
+ this.getWindow().removeEventListener("resize", this.onResize);
+ }
+}