diff options
Diffstat (limited to 'browser/components/firefoxview/recently-closed-tabs.mjs')
-rw-r--r-- | browser/components/firefoxview/recently-closed-tabs.mjs | 488 |
1 files changed, 488 insertions, 0 deletions
diff --git a/browser/components/firefoxview/recently-closed-tabs.mjs b/browser/components/firefoxview/recently-closed-tabs.mjs new file mode 100644 index 0000000000..e3a207fc5d --- /dev/null +++ b/browser/components/firefoxview/recently-closed-tabs.mjs @@ -0,0 +1,488 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", +}); + +import { + formatURIForDisplay, + convertTimestamp, + createFaviconElement, + onToggleContainer, + NOW_THRESHOLD_MS, +} from "./helpers.mjs"; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const SS_NOTIFY_CLOSED_OBJECTS_CHANGED = "sessionstore-closed-objects-changed"; +const SS_NOTIFY_BROWSER_SHUTDOWN_FLUSH = "sessionstore-browser-shutdown-flush"; +const UI_OPEN_STATE = + "browser.tabs.firefox-view.ui-state.recently-closed-tabs.open"; + +function getWindow() { + return window.browsingContext.embedderWindowGlobal.browsingContext.window; +} + +class RecentlyClosedTabsList extends HTMLElement { + constructor() { + super(); + this.maxTabsLength = 25; + this.closedTabsData = new Map(); + + // The recency timestamp update period is stored in a pref to allow tests to easily change it + XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "timeMsPref", + "browser.tabs.firefox-view.updateTimeMs", + NOW_THRESHOLD_MS, + () => this.updateTime() + ); + } + + get tabsList() { + return this.querySelector("ol"); + } + + get fluentStrings() { + if (!this._fluentStrings) { + this._fluentStrings = new Localization(["browser/firefoxView.ftl"], true); + } + return this._fluentStrings; + } + + get timeElements() { + return this.querySelectorAll("span.closed-tab-li-time"); + } + + connectedCallback() { + this.addEventListener("click", this); + this.addEventListener("keydown", this); + this.intervalID = setInterval(() => this.updateTime(), lazy.timeMsPref); + } + + disconnectedCallback() { + clearInterval(this.intervalID); + } + + handleEvent(event) { + if ( + (event.type == "click" && !event.altKey) || + (event.type == "keydown" && event.keyCode == KeyEvent.DOM_VK_RETURN) || + (event.type == "keydown" && event.keyCode == KeyEvent.DOM_VK_SPACE) + ) { + if (!event.target.classList.contains("closed-tab-li-dismiss")) { + this.openTabAndUpdate(event); + } else { + this.dismissTabAndUpdate(event); + } + } + } + + updateTime() { + for (let timeEl of this.timeElements) { + timeEl.textContent = convertTimestamp( + parseInt(timeEl.getAttribute("data-timestamp")), + this.fluentStrings, + lazy.timeMsPref + ); + } + } + + getTabStateValue(tab, key) { + let value = ""; + const tabEntries = tab.state.entries; + const activeIndex = tab.state.index - 1; + + if (activeIndex >= 0 && tabEntries[activeIndex]) { + value = tabEntries[activeIndex][key]; + } + + return value; + } + + focusFirstItemOrHeader(dismissedIndex) { + // When a tab is removed from the list, the focus should + // remain on the list or the list header. This prevents context + // switching when navigating back to Firefox View. + let recentlyClosedList = [...this.tabsList.children]; + if (recentlyClosedList.length) { + recentlyClosedList.forEach(element => + element.setAttribute("tabindex", "-1") + ); + let mainContent; + if (dismissedIndex) { + // Select the item above the one that was just dismissed + mainContent = recentlyClosedList[dismissedIndex - 1].querySelector( + ".closed-tab-li-main" + ); + } else { + mainContent = recentlyClosedList[0].querySelector( + ".closed-tab-li-main" + ); + } + mainContent.setAttribute("tabindex", "0"); + mainContent.focus(); + } else { + document.getElementById("recently-closed-tabs-header-section").focus(); + } + } + + openTabAndUpdate(event) { + event.preventDefault(); + const item = event.target.closest(".closed-tab-li"); + // only used for telemetry + const position = [...this.tabsList.children].indexOf(item) + 1; + const closedId = item.dataset.tabid; + + lazy.SessionStore.undoCloseById(closedId); + this.tabsList.removeChild(item); + + this.focusFirstItemOrHeader(); + + // record telemetry + let tabClosedAt = parseInt( + item.querySelector(".closed-tab-li-time").getAttribute("data-timestamp") + ); + + let now = Date.now(); + let deltaSeconds = (now - tabClosedAt) / 1000; + Services.telemetry.recordEvent( + "firefoxview", + "recently_closed", + "tabs", + null, + { + position: position.toString(), + delta: deltaSeconds.toString(), + } + ); + } + + dismissTabAndUpdate(event) { + event.preventDefault(); + const item = event.target.closest(".closed-tab-li"); + let recentlyClosedList = lazy.SessionStore.getClosedTabData(getWindow()); + let closedTabIndex = recentlyClosedList.findIndex(closedTab => { + return closedTab.closedId === parseInt(item.dataset.tabid, 10); + }); + if (closedTabIndex < 0) { + // Tab not found in recently closed list + return; + } + this.tabsList.removeChild(item); + lazy.SessionStore.forgetClosedTab(getWindow(), closedTabIndex); + + this.focusFirstItemOrHeader(closedTabIndex); + + // record telemetry + let tabClosedAt = parseInt( + item.querySelector(".closed-tab-li-time").dataset.timestamp + ); + + let now = Date.now(); + let deltaSeconds = (now - tabClosedAt) / 1000; + Services.telemetry.recordEvent( + "firefoxview", + "dismiss_closed_tab", + "tabs", + null, + { + delta: deltaSeconds.toString(), + } + ); + } + + updateTabsList() { + let newClosedTabs = lazy.SessionStore.getClosedTabData(getWindow()); + newClosedTabs = newClosedTabs.slice(0, this.maxTabsLength); + + if (this.closedTabsData.size && !newClosedTabs.length) { + // if a user purges history, clear the list + while (this.tabsList.lastElementChild) { + this.tabsList.lastElementChild.remove(); + } + document + .getElementById("recently-closed-tabs-container") + .togglePlaceholderVisibility(true); + this.tabsList.hidden = true; + this.closedTabsData = new Map(); + return; + } + + // First purge obsolete items out of the map so we don't leak them forever: + for (let id of this.closedTabsData.keys()) { + if (!newClosedTabs.some(t => t.closedId == id)) { + this.closedTabsData.delete(id); + } + } + + // Then work out which of the new closed tabs are additions and which update + // existing items: + let tabsToAdd = []; + let tabsToUpdate = []; + for (let newTab of newClosedTabs) { + let oldTab = this.closedTabsData.get(newTab.closedId); + this.closedTabsData.set(newTab.closedId, newTab); + if (!oldTab) { + tabsToAdd.push(newTab); + } else if ( + this.getTabStateValue(oldTab, "url") != + this.getTabStateValue(newTab, "url") + ) { + tabsToUpdate.push(newTab); + } + } + + // Remove existing tabs from tabsList if not in latest closedTabsData + // which is necessary when using "Reopen Closed Tab" from the toolbar + // or when selecting "Forget this site" in History + [...this.tabsList.children].forEach(existingTab => { + if (!this.closedTabsData.get(parseInt(existingTab.dataset.tabid, 10))) { + this.tabsList.removeChild(existingTab); + } + }); + + // If there's nothing to add/update, return. + if (!tabsToAdd.length && !tabsToUpdate.length) { + return; + } + + // Add new tabs. + for (let tab of tabsToAdd.reverse()) { + if (this.tabsList.children.length == this.maxTabsLength) { + this.tabsList.lastChild.remove(); + } + let li = this.generateListItem(tab); + let mainContent = li.querySelector(".closed-tab-li-main"); + // Only the first item in the list should be focusable + if (!this.tabsList.children.length) { + mainContent.setAttribute("tabindex", "0"); + } else if (this.tabsList.children.length) { + mainContent.setAttribute("tabindex", "0"); + this.tabsList.children[0].setAttribute("tabindex", "-1"); + } + this.tabsList.prepend(li); + } + + // Update any recently closed tabs that now have different URLs: + for (let tab of tabsToUpdate) { + let tabElement = this.querySelector( + `.closed-tab-li[data-tabid="${tab.closedId}"]` + ); + let url = this.getTabStateValue(tab, "url"); + this.updateURLForListItem(tabElement, url); + } + + // Now unhide the list if necessary: + if (this.tabsList.hidden) { + this.tabsList.hidden = false; + document + .getElementById("recently-closed-tabs-container") + .togglePlaceholderVisibility(false); + } + } + + generateListItem(tab) { + const li = document.createElement("li"); + li.classList.add("closed-tab-li"); + li.dataset.tabid = tab.closedId; + + const title = document.createElement("span"); + title.textContent = `${tab.title}`; + title.classList.add("closed-tab-li-title"); + + const targetURI = this.getTabStateValue(tab, "url"); + const image = tab.image; + const favicon = createFaviconElement(image, targetURI); + + const urlElement = document.createElement("span"); + urlElement.classList.add("closed-tab-li-url"); + + const time = document.createElement("span"); + const convertedTime = convertTimestamp(tab.closedAt, this.fluentStrings); + time.textContent = convertedTime; + time.setAttribute("data-timestamp", tab.closedAt); + time.classList.add("closed-tab-li-time"); + + const mainContent = document.createElement("span"); + mainContent.classList.add("closed-tab-li-main"); + mainContent.setAttribute("role", "link"); + mainContent.setAttribute("tabindex", 0); + mainContent.append(favicon, title, urlElement, time); + + const dismissButton = document.createElement("button"); + let tabTitle = tab.title ?? ""; + document.l10n.setAttributes( + dismissButton, + "firefoxview-closed-tabs-dismiss-tab", + { + tabTitle, + } + ); + dismissButton.classList.add("closed-tab-li-dismiss"); + + li.append(mainContent, dismissButton); + this.updateURLForListItem(li, targetURI); + return li; + } + + // Update the URL for a new or previously-populated list item. + // This is needed because when tabs get closed we don't necessarily + // have all the requisite information for them immediately. + updateURLForListItem(li, targetURI) { + li.dataset.targetURI = targetURI; + let urlElement = li.querySelector(".closed-tab-li-url"); + document.l10n.setAttributes( + urlElement, + "firefoxview-tabs-list-tab-button", + { + targetURI, + } + ); + if (targetURI) { + urlElement.textContent = formatURIForDisplay(targetURI); + urlElement.title = targetURI; + } else { + urlElement.textContent = urlElement.title = ""; + } + } +} +customElements.define("recently-closed-tabs-list", RecentlyClosedTabsList); + +class RecentlyClosedTabsContainer extends HTMLDetailsElement { + constructor() { + super(); + this.observerAdded = false; + this.boundObserve = (...args) => this.observe(...args); + } + + connectedCallback() { + this.noTabsElement = this.querySelector( + "#recently-closed-tabs-placeholder" + ); + this.list = this.querySelector("recently-closed-tabs-list"); + this.collapsibleContainer = this.querySelector( + "#collapsible-tabs-container" + ); + this.addEventListener("toggle", this); + getWindow().gBrowser.tabContainer.addEventListener("TabSelect", this); + this.open = Services.prefs.getBoolPref(UI_OPEN_STATE, true); + } + + cleanup() { + getWindow().gBrowser.tabContainer.removeEventListener("TabSelect", this); + this.removeObserversIfNeeded(); + } + + addObserversIfNeeded() { + if (!this.observerAdded) { + Services.obs.addObserver( + this.boundObserve, + SS_NOTIFY_CLOSED_OBJECTS_CHANGED + ); + Services.obs.addObserver( + this.boundObserve, + SS_NOTIFY_BROWSER_SHUTDOWN_FLUSH + ); + this.observerAdded = true; + } + } + + removeObserversIfNeeded() { + if (this.observerAdded) { + Services.obs.removeObserver( + this.boundObserve, + SS_NOTIFY_CLOSED_OBJECTS_CHANGED + ); + Services.obs.removeObserver( + this.boundObserve, + SS_NOTIFY_BROWSER_SHUTDOWN_FLUSH + ); + this.observerAdded = false; + } + } + + // we observe when a tab closes but since this notification fires more frequently and on + // all windows, we remove the observer when another tab is selected; we check for changes + // to the session store once the user return to this tab. + handleObservers(contentDocument) { + if (contentDocument?.URL == "about:firefoxview") { + this.addObserversIfNeeded(); + this.list.updateTabsList(); + this.maybeUpdateFocus(); + } else { + this.removeObserversIfNeeded(); + } + } + + observe(subject, topic, data) { + if ( + topic == SS_NOTIFY_CLOSED_OBJECTS_CHANGED || + (topic == SS_NOTIFY_BROWSER_SHUTDOWN_FLUSH && + subject.ownerGlobal == getWindow()) + ) { + this.list.updateTabsList(); + } + } + + onLoad() { + if (this.getClosedTabCount() == 0) { + this.togglePlaceholderVisibility(true); + } else { + this.list.updateTabsList(); + } + this.addObserversIfNeeded(); + } + + handleEvent(event) { + if (event.type == "toggle") { + onToggleContainer(this); + } else if (event.type == "TabSelect") { + this.handleObservers(event.target.linkedBrowser.contentDocument); + } + } + + /** + * Manages focus when returning to the Firefox View tab + * + * @memberof RecentlyClosedTabsContainer + */ + maybeUpdateFocus() { + // Check if focus is in the container element + if (this.contains(document.activeElement)) { + let listItems = this.list.querySelectorAll("li"); + // More tabs may have been added to the list, so we'll refocus + // the first item in the list. + if (listItems.length) { + listItems[0].querySelector(".closed-tab-li-main").focus(); + } else { + this.querySelector("summary").focus(); + } + } + } + + togglePlaceholderVisibility(visible) { + this.noTabsElement.toggleAttribute("hidden", !visible); + this.collapsibleContainer.classList.toggle("empty-container", visible); + } + + getClosedTabCount = () => { + try { + return lazy.SessionStore.getClosedTabCount(getWindow()); + } catch (ex) { + return 0; + } + }; +} +customElements.define( + "recently-closed-tabs-container", + RecentlyClosedTabsContainer, + { + extends: "details", + } +); |