Backed out changeset 7eda7968bffa (bug 1869833) for causing mochitests failures in...
[gecko.git] / browser / components / firefoxview / recently-closed-tabs.mjs
blob4114d40dfcc39ace9e2d68dd9b5d2e17a4ed6d75
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 const lazy = {};
6 ChromeUtils.defineESModuleGetters(lazy, {
7   SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs",
8 });
10 import {
11   formatURIForDisplay,
12   convertTimestamp,
13   getImageUrl,
14   onToggleContainer,
15   NOW_THRESHOLD_MS,
16 } from "./helpers.mjs";
18 import {
19   html,
20   ifDefined,
21   styleMap,
22 } from "chrome://global/content/vendor/lit.all.mjs";
23 import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
25 const { XPCOMUtils } = ChromeUtils.importESModule(
26   "resource://gre/modules/XPCOMUtils.sys.mjs"
29 const SS_NOTIFY_CLOSED_OBJECTS_CHANGED = "sessionstore-closed-objects-changed";
30 const SS_NOTIFY_BROWSER_SHUTDOWN_FLUSH = "sessionstore-browser-shutdown-flush";
31 const UI_OPEN_STATE =
32   "browser.tabs.firefox-view.ui-state.recently-closed-tabs.open";
34 function getWindow() {
35   return window.browsingContext.embedderWindowGlobal.browsingContext.window;
38 class RecentlyClosedTabsList extends MozLitElement {
39   constructor() {
40     super();
41     this.maxTabsLength = 25;
42     this.recentlyClosedTabs = [];
43     this.lastFocusedIndex = -1;
45     // The recency timestamp update period is stored in a pref to allow tests to easily change it
46     XPCOMUtils.defineLazyPreferenceGetter(
47       lazy,
48       "timeMsPref",
49       "browser.tabs.firefox-view.updateTimeMs",
50       NOW_THRESHOLD_MS,
51       timeMsPref => {
52         clearInterval(this.intervalID);
53         this.intervalID = setInterval(() => this.requestUpdate(), timeMsPref);
54         this.requestUpdate();
55       }
56     );
57   }
59   createRenderRoot() {
60     return this;
61   }
63   static queries = {
64     tabsList: "ol",
65     timeElements: { all: "span.closed-tab-li-time" },
66   };
68   get fluentStrings() {
69     if (!this._fluentStrings) {
70       this._fluentStrings = new Localization(["browser/firefoxView.ftl"], true);
71     }
72     return this._fluentStrings;
73   }
75   connectedCallback() {
76     super.connectedCallback();
77     this.intervalID = setInterval(() => this.requestUpdate(), lazy.timeMsPref);
78   }
80   disconnectedCallback() {
81     super.disconnectedCallback();
82     clearInterval(this.intervalID);
83   }
85   getTabStateValue(tab, key) {
86     let value = "";
87     const tabEntries = tab.entries;
88     const activeIndex = tab.index - 1;
90     if (activeIndex >= 0 && tabEntries[activeIndex]) {
91       value = tabEntries[activeIndex][key];
92     }
94     return value;
95   }
97   openTabAndUpdate(event) {
98     if (
99       (event.type == "click" && !event.altKey) ||
100       (event.type == "keydown" && event.code == "Enter") ||
101       (event.type == "keydown" && event.code == "Space")
102     ) {
103       const item = event.target.closest(".closed-tab-li");
104       // only used for telemetry
105       const position = [...this.tabsList.children].indexOf(item) + 1;
106       const closedId = item.dataset.tabid;
108       lazy.SessionStore.undoCloseById(closedId, undefined, getWindow());
110       // record telemetry
111       let tabClosedAt = parseInt(
112         item.querySelector(".closed-tab-li-time").getAttribute("data-timestamp")
113       );
115       let now = Date.now();
116       let deltaSeconds = (now - tabClosedAt) / 1000;
117       Services.telemetry.recordEvent(
118         "firefoxview",
119         "recently_closed",
120         "tabs",
121         null,
122         {
123           position: position.toString(),
124           delta: deltaSeconds.toString(),
125         }
126       );
127     }
128   }
130   dismissTabAndUpdate(event) {
131     event.preventDefault();
132     const item = event.target.closest(".closed-tab-li");
133     this.dismissTabAndUpdateForElement(item);
134   }
136   dismissTabAndUpdateForElement(item) {
137     const sourceWindow = lazy.SessionStore.getWindowById(
138       item.dataset.sourceWindowId
139     );
140     let recentlyClosedList =
141       lazy.SessionStore.getClosedTabDataForWindow(sourceWindow);
142     let closedTabIndex = recentlyClosedList.findIndex(closedTab => {
143       return closedTab.closedId === parseInt(item.dataset.tabid, 10);
144     });
145     if (closedTabIndex < 0) {
146       // Tab not found in recently closed list
147       return;
148     }
149     // in order forget a closed tab, we need to pass the window the closed tab data is associated with
150     lazy.SessionStore.forgetClosedTab(sourceWindow, closedTabIndex);
152     // record telemetry
153     let tabClosedAt = parseInt(
154       item.querySelector(".closed-tab-li-time").dataset.timestamp
155     );
157     let now = Date.now();
158     let deltaSeconds = (now - tabClosedAt) / 1000;
159     Services.telemetry.recordEvent(
160       "firefoxview",
161       "dismiss_closed_tab",
162       "tabs",
163       null,
164       {
165         delta: deltaSeconds.toString(),
166       }
167     );
168   }
170   getClosedTabsDataForOpenWindows() {
171     // get closed tabs in currently-open windows
172     const closedTabsData = lazy.SessionStore.getClosedTabData(getWindow()).map(
173       tabData => {
174         // flatten the object; move properties of `.state` into the top-level object
175         const stateData = tabData.state;
176         delete tabData.state;
177         return {
178           ...tabData,
179           ...stateData,
180         };
181       }
182     );
183     return closedTabsData;
184   }
186   updateRecentlyClosedTabs() {
187     // add recently-closed tabs from currently-open windows
188     const recentlyClosedTabsData = this.getClosedTabsDataForOpenWindows();
190     recentlyClosedTabsData.sort((a, b) => a.closedAt < b.closedAt);
191     this.recentlyClosedTabs = recentlyClosedTabsData.slice(
192       0,
193       this.maxTabsLength
194     );
195     this.requestUpdate();
196   }
198   render() {
199     let { recentlyClosedTabs } = this;
200     let closedTabsContainer = document.getElementById(
201       "recently-closed-tabs-container"
202     );
204     if (!recentlyClosedTabs.length) {
205       // Show empty message if no recently closed tabs
206       closedTabsContainer.toggleContainerStyleForEmptyMsg(true);
207       return html` ${this.emptyMessageTemplate()} `;
208     }
210     closedTabsContainer.toggleContainerStyleForEmptyMsg(false);
212     return html`
213       <ol class="closed-tabs-list">
214         ${recentlyClosedTabs.map((tab, i) =>
215           this.recentlyClosedTabTemplate(tab, !i)
216         )}
217       </ol>
218     `;
219   }
221   willUpdate() {
222     if (this.tabsList && this.tabsList.contains(document.activeElement)) {
223       let activeLi = document.activeElement.closest(".closed-tab-li");
224       this.lastFocusedIndex = [...this.tabsList.children].indexOf(activeLi);
225     } else {
226       this.lastFocusedIndex = -1;
227     }
228   }
230   updated() {
231     let focusRestored = false;
232     if (
233       this.lastFocusedIndex >= 0 &&
234       (!this.tabsList || this.lastFocusedIndex >= this.tabsList.children.length)
235     ) {
236       if (this.tabsList) {
237         let items = [...this.tabsList.children];
238         let newFocusIndex = items.length - 1;
239         let newFocus = items[newFocusIndex];
240         if (newFocus) {
241           focusRestored = true;
242           newFocus.querySelector(".closed-tab-li-main").focus();
243         }
244       }
245       if (!focusRestored) {
246         document.getElementById("recently-closed-tabs-header-section").focus();
247       }
248     }
249     this.lastFocusedIndex = -1;
250   }
252   emptyMessageTemplate() {
253     return html`
254       <div
255         id="recently-closed-tabs-placeholder"
256         class="placeholder-content"
257         role="presentation"
258       >
259         <img
260           id="recently-closed-empty-image"
261           src="chrome://browser/content/firefoxview/recently-closed-empty.svg"
262           role="presentation"
263           alt=""
264         />
265         <div class="placeholder-text">
266           <h4
267             data-l10n-id="firefoxview-closed-tabs-placeholder-header"
268             class="placeholder-header"
269           ></h4>
270           <p
271             data-l10n-id="firefoxview-closed-tabs-placeholder-body2"
272             class="placeholder-body"
273           ></p>
274         </div>
275       </div>
276     `;
277   }
279   recentlyClosedTabTemplate(tab, primary) {
280     const targetURI = this.getTabStateValue(tab, "url");
281     const convertedTime = convertTimestamp(
282       tab.closedAt,
283       this.fluentStrings,
284       lazy.timeMsPref
285     );
286     return html`
287       <li
288         class="closed-tab-li"
289         data-tabid=${tab.closedId}
290         data-source-window-id=${tab.sourceWindowId}
291         data-targeturi=${targetURI}
292         tabindex=${ifDefined(primary ? null : "-1")}
293         @contextmenu=${e => (this.contextTriggerNode = e.currentTarget)}
294       >
295         <span
296           class="closed-tab-li-main"
297           role="button"
298           tabindex="0"
299           @click=${e => this.openTabAndUpdate(e)}
300           @keydown=${e => this.openTabAndUpdate(e)}
301         >
302           <div
303             class="favicon"
304             style=${styleMap({
305               backgroundImage: `url(${getImageUrl(tab.icon, targetURI)})`,
306             })}
307           ></div>
308           <a
309             href=${targetURI}
310             class="closed-tab-li-title"
311             tabindex="-1"
312             @click=${e => e.preventDefault()}
313           >
314             ${tab.title}
315           </a>
316           <a
317             href=${targetURI}
318             class="closed-tab-li-url"
319             data-l10n-id="firefoxview-tabs-list-tab-button"
320             data-l10n-args=${JSON.stringify({ targetURI })}
321             tabindex="-1"
322             @click=${e => e.preventDefault()}
323           >
324             ${formatURIForDisplay(targetURI)}
325           </a>
326           <span class="closed-tab-li-time" data-timestamp=${tab.closedAt}>
327             ${convertedTime}
328           </span>
329         </span>
330         <button
331           class="closed-tab-li-dismiss"
332           data-l10n-id="firefoxview-closed-tabs-dismiss-tab"
333           data-l10n-args=${JSON.stringify({ tabTitle: tab.title })}
334           @click=${e => this.dismissTabAndUpdate(e)}
335         ></button>
336       </li>
337     `;
338   }
340   // Update the URL for a new or previously-populated list item.
341   // This is needed because when tabs get closed we don't necessarily
342   // have all the requisite information for them immediately.
343   updateURLForListItem(li, targetURI) {
344     li.dataset.targetURI = targetURI;
345     let urlElement = li.querySelector(".closed-tab-li-url");
346     document.l10n.setAttributes(
347       urlElement,
348       "firefoxview-tabs-list-tab-button",
349       {
350         targetURI,
351       }
352     );
353     if (targetURI) {
354       urlElement.textContent = formatURIForDisplay(targetURI);
355       urlElement.title = targetURI;
356     } else {
357       urlElement.textContent = urlElement.title = "";
358     }
359   }
361 customElements.define("recently-closed-tabs-list", RecentlyClosedTabsList);
363 class RecentlyClosedTabsContainer extends HTMLDetailsElement {
364   constructor() {
365     super();
366     this.observerAdded = false;
367     this.boundObserve = (...args) => this.observe(...args);
368   }
370   connectedCallback() {
371     this.noTabsElement = this.querySelector(
372       "#recently-closed-tabs-placeholder"
373     );
374     this.list = this.querySelector("recently-closed-tabs-list");
375     this.collapsibleContainer = this.querySelector(
376       "#collapsible-tabs-container"
377     );
378     this.addEventListener("toggle", this);
379     getWindow().gBrowser.tabContainer.addEventListener("TabSelect", this);
380     getWindow().addEventListener("command", this, true);
381     getWindow()
382       .document.getElementById("contentAreaContextMenu")
383       .addEventListener("popuphiding", this);
384     this.open = Services.prefs.getBoolPref(UI_OPEN_STATE, true);
385   }
387   cleanup() {
388     getWindow().gBrowser.tabContainer.removeEventListener("TabSelect", this);
389     getWindow().removeEventListener("command", this, true);
390     getWindow()
391       .document.getElementById("contentAreaContextMenu")
392       .removeEventListener("popuphiding", this);
393     this.removeObserversIfNeeded();
394   }
396   addObserversIfNeeded() {
397     if (!this.observerAdded) {
398       Services.obs.addObserver(
399         this.boundObserve,
400         SS_NOTIFY_CLOSED_OBJECTS_CHANGED
401       );
402       Services.obs.addObserver(
403         this.boundObserve,
404         SS_NOTIFY_BROWSER_SHUTDOWN_FLUSH
405       );
406       this.observerAdded = true;
407     }
408   }
410   removeObserversIfNeeded() {
411     if (this.observerAdded) {
412       Services.obs.removeObserver(
413         this.boundObserve,
414         SS_NOTIFY_CLOSED_OBJECTS_CHANGED
415       );
416       Services.obs.removeObserver(
417         this.boundObserve,
418         SS_NOTIFY_BROWSER_SHUTDOWN_FLUSH
419       );
420       this.observerAdded = false;
421     }
422   }
424   // we observe when a tab closes but since this notification fires more frequently and on
425   // all windows, we remove the observer when another tab is selected; we check for changes
426   // to the session store once the user return to this tab.
427   handleObservers(contentDocument) {
428     if (contentDocument?.URL == "about:firefoxview") {
429       this.addObserversIfNeeded();
430       this.list.updateRecentlyClosedTabs();
431     } else {
432       this.removeObserversIfNeeded();
433     }
434   }
436   observe(subject, topic, data) {
437     if (
438       topic == SS_NOTIFY_CLOSED_OBJECTS_CHANGED ||
439       (topic == SS_NOTIFY_BROWSER_SHUTDOWN_FLUSH &&
440         subject.ownerGlobal == getWindow())
441     ) {
442       this.list.updateRecentlyClosedTabs();
443     }
444   }
446   onLoad() {
447     this.list.updateRecentlyClosedTabs();
448     this.addObserversIfNeeded();
449   }
451   handleEvent(event) {
452     if (event.type == "toggle") {
453       onToggleContainer(this);
454     } else if (event.type == "TabSelect") {
455       this.handleObservers(event.target.linkedBrowser.contentDocument);
456     } else if (
457       event.type === "command" &&
458       event.target.closest(".context-menu-open-link") &&
459       this.list.contextTriggerNode
460     ) {
461       this.list.dismissTabAndUpdateForElement(this.list.contextTriggerNode);
462     } else if (event.type === "popuphiding") {
463       delete this.list.contextTriggerNode;
464     }
465   }
467   toggleContainerStyleForEmptyMsg(visible) {
468     this.collapsibleContainer.classList.toggle("empty-container", visible);
469   }
471 customElements.define(
472   "recently-closed-tabs-container",
473   RecentlyClosedTabsContainer,
474   {
475     extends: "details",
476   }