diff options
Diffstat (limited to 'browser/components/firefoxview')
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, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} 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); + } +} |