summaryrefslogtreecommitdiffstats
path: root/browser/components/firefoxview/recently-closed-tabs.mjs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--browser/components/firefoxview/recently-closed-tabs.mjs488
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",
+ }
+);