Bug 1944416: Restore individual tabs from closed groups in closed windows r=dao,sessi...
[gecko.git] / browser / components / firefoxview / recentlyclosed.mjs
blobb3a18d7b08357fd7c214bec20d3af01369ce64ce
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 import {
6   classMap,
7   html,
8   ifDefined,
9   when,
10 } from "chrome://global/content/vendor/lit.all.mjs";
11 import { MAX_TABS_FOR_RECENT_BROWSING } from "./helpers.mjs";
12 import { searchTabList } from "./search-helpers.mjs";
13 import { ViewPage } from "./viewpage.mjs";
14 // eslint-disable-next-line import/no-unassigned-import
15 import "chrome://browser/content/firefoxview/card-container.mjs";
16 // eslint-disable-next-line import/no-unassigned-import
17 import "chrome://browser/content/firefoxview/fxview-tab-list.mjs";
19 const lazy = {};
20 ChromeUtils.defineESModuleGetters(lazy, {
21   SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs",
22 });
24 const SS_NOTIFY_CLOSED_OBJECTS_CHANGED = "sessionstore-closed-objects-changed";
25 const SS_NOTIFY_BROWSER_SHUTDOWN_FLUSH = "sessionstore-browser-shutdown-flush";
26 const NEVER_REMEMBER_HISTORY_PREF = "browser.privatebrowsing.autostart";
27 const INCLUDE_CLOSED_TABS_FROM_CLOSED_WINDOWS =
28   "browser.sessionstore.closedTabsFromClosedWindows";
30 function getWindow() {
31   return window.browsingContext.embedderWindowGlobal.browsingContext.window;
34 class RecentlyClosedTabsInView extends ViewPage {
35   constructor() {
36     super();
37     this._started = false;
38     this.boundObserve = (...args) => this.observe(...args);
39     this.firstUpdateComplete = false;
40     this.fullyUpdated = false;
41     this.maxTabsLength = this.recentBrowsing
42       ? MAX_TABS_FOR_RECENT_BROWSING
43       : -1;
44     this.recentlyClosedTabs = [];
45     this.searchQuery = "";
46     this.searchResults = null;
47     this.showAll = false;
48     this.cumulativeSearches = 0;
49   }
51   static properties = {
52     ...ViewPage.properties,
53     searchResults: { type: Array },
54     showAll: { type: Boolean },
55     cumulativeSearches: { type: Number },
56   };
58   static queries = {
59     cardEl: "card-container",
60     emptyState: "fxview-empty-state",
61     searchTextbox: "fxview-search-textbox",
62     tabList: "fxview-tab-list",
63   };
65   observe(subject, topic) {
66     if (
67       topic == SS_NOTIFY_CLOSED_OBJECTS_CHANGED ||
68       (topic == SS_NOTIFY_BROWSER_SHUTDOWN_FLUSH &&
69         subject.ownerGlobal == getWindow())
70     ) {
71       this.updateRecentlyClosedTabs();
72     }
73   }
75   start() {
76     if (this._started) {
77       return;
78     }
79     this._started = true;
80     this.paused = false;
81     this.updateRecentlyClosedTabs();
83     Services.obs.addObserver(
84       this.boundObserve,
85       SS_NOTIFY_CLOSED_OBJECTS_CHANGED
86     );
87     Services.obs.addObserver(
88       this.boundObserve,
89       SS_NOTIFY_BROWSER_SHUTDOWN_FLUSH
90     );
92     if (this.recentBrowsing) {
93       this.recentBrowsingElement.addEventListener(
94         "fxview-search-textbox-query",
95         this
96       );
97     }
99     this.toggleVisibilityInCardContainer();
100   }
102   stop() {
103     if (!this._started) {
104       return;
105     }
106     this._started = false;
108     Services.obs.removeObserver(
109       this.boundObserve,
110       SS_NOTIFY_CLOSED_OBJECTS_CHANGED
111     );
112     Services.obs.removeObserver(
113       this.boundObserve,
114       SS_NOTIFY_BROWSER_SHUTDOWN_FLUSH
115     );
117     if (this.recentBrowsing) {
118       this.recentBrowsingElement.removeEventListener(
119         "fxview-search-textbox-query",
120         this
121       );
122     }
124     this.toggleVisibilityInCardContainer();
125   }
127   disconnectedCallback() {
128     super.disconnectedCallback();
129     this.stop();
130   }
132   handleEvent(event) {
133     if (this.recentBrowsing && event.type === "fxview-search-textbox-query") {
134       this.onSearchQuery(event);
135     }
136   }
138   // We remove all the observers when the instance is not visible to the user
139   viewHiddenCallback() {
140     this.stop();
141   }
143   // We add observers and check for changes to the session store once the user return to this tab.
144   // or the instance becomes visible to the user
145   viewVisibleCallback() {
146     this.start();
147   }
149   firstUpdated() {
150     this.firstUpdateComplete = true;
151   }
153   getTabStateValue(tab, key) {
154     let value = "";
155     const tabEntries = tab.state.entries;
156     const activeIndex = tab.state.index - 1;
158     if (activeIndex >= 0 && tabEntries[activeIndex]) {
159       value = tabEntries[activeIndex][key];
160     }
162     return value;
163   }
165   updateRecentlyClosedTabs() {
166     let recentlyClosedTabsData =
167       lazy.SessionStore.getClosedTabData(getWindow());
168     if (Services.prefs.getBoolPref(INCLUDE_CLOSED_TABS_FROM_CLOSED_WINDOWS)) {
169       recentlyClosedTabsData.push(
170         ...lazy.SessionStore.getClosedTabDataFromClosedWindows()
171       );
172     }
173     // sort the aggregated list to most-recently-closed first
174     recentlyClosedTabsData.sort((a, b) => a.closedAt < b.closedAt);
175     this.recentlyClosedTabs = recentlyClosedTabsData;
176     this.normalizeRecentlyClosedData();
177     if (this.searchQuery) {
178       this.#updateSearchResults();
179     }
180     this.requestUpdate();
181   }
183   normalizeRecentlyClosedData() {
184     // Normalize data for fxview-tabs-list
185     this.recentlyClosedTabs.forEach(recentlyClosedItem => {
186       const targetURI = this.getTabStateValue(recentlyClosedItem, "url");
187       recentlyClosedItem.time = recentlyClosedItem.closedAt;
188       recentlyClosedItem.icon = recentlyClosedItem.image;
189       recentlyClosedItem.primaryL10nId = "fxviewtabrow-tabs-list-tab";
190       recentlyClosedItem.primaryL10nArgs = JSON.stringify({
191         targetURI: typeof targetURI === "string" ? targetURI : "",
192       });
193       recentlyClosedItem.secondaryL10nId =
194         "firefoxview-closed-tabs-dismiss-tab";
195       recentlyClosedItem.secondaryL10nArgs = JSON.stringify({
196         tabTitle: recentlyClosedItem.title,
197       });
198       recentlyClosedItem.url = targetURI;
199     });
200   }
202   onReopenTab(e) {
203     const closedId = parseInt(e.originalTarget.closedId, 10);
204     const sourceClosedId = parseInt(e.originalTarget.sourceClosedId, 10);
205     if (isNaN(sourceClosedId)) {
206       lazy.SessionStore.undoCloseById(closedId, getWindow());
207     } else {
208       lazy.SessionStore.undoClosedTabFromClosedWindow(
209         { sourceClosedId },
210         closedId,
211         getWindow()
212       );
213     }
215     // Record telemetry
216     let tabClosedAt = parseInt(e.originalTarget.time);
217     const position =
218       Array.from(this.tabList.rowEls).indexOf(e.originalTarget) + 1;
220     let now = Date.now();
221     let deltaSeconds = (now - tabClosedAt) / 1000;
222     Glean.firefoxviewNext.recentlyClosedTabs.record({
223       position,
224       delta: deltaSeconds,
225       page: this.recentBrowsing ? "recentbrowsing" : "recentlyclosed",
226     });
227     if (this.searchQuery) {
228       Services.telemetry
229         .getKeyedHistogramById("FIREFOX_VIEW_CUMULATIVE_SEARCHES")
230         .add(
231           this.recentBrowsing ? "recentbrowsing" : "recentlyclosed",
232           this.cumulativeSearches
233         );
234       this.cumulativeSearches = 0;
235     }
236   }
238   onDismissTab(e) {
239     const closedId = parseInt(e.originalTarget.closedId, 10);
240     const sourceClosedId = parseInt(e.originalTarget.sourceClosedId, 10);
241     const sourceWindowId = e.originalTarget.sourceWindowId;
242     if (!isNaN(sourceClosedId)) {
243       // the sourceClosedId is an identifier for a now-closed window the tab
244       // was closed in.
245       lazy.SessionStore.forgetClosedTabById(closedId, {
246         sourceClosedId,
247       });
248     } else if (sourceWindowId) {
249       // the sourceWindowId is an identifier for a currently-open window the tab
250       // was closed in.
251       lazy.SessionStore.forgetClosedTabById(closedId, {
252         sourceWindowId,
253       });
254     } else {
255       // without either identifier, SessionStore will need to walk its window collections
256       // to find the close tab with matching closedId
257       lazy.SessionStore.forgetClosedTabById(closedId);
258     }
260     // Record telemetry
261     let tabClosedAt = parseInt(e.originalTarget.time);
262     const position =
263       Array.from(this.tabList.rowEls).indexOf(e.originalTarget) + 1;
265     let now = Date.now();
266     let deltaSeconds = (now - tabClosedAt) / 1000;
267     Glean.firefoxviewNext.dismissClosedTabTabs.record({
268       position,
269       delta: deltaSeconds,
270       page: this.recentBrowsing ? "recentbrowsing" : "recentlyclosed",
271     });
272   }
274   willUpdate() {
275     this.fullyUpdated = false;
276   }
278   updated() {
279     this.fullyUpdated = true;
280     this.toggleVisibilityInCardContainer();
281   }
283   async scheduleUpdate() {
284     // Only defer initial update
285     if (!this.firstUpdateComplete) {
286       await new Promise(resolve => setTimeout(resolve));
287     }
288     super.scheduleUpdate();
289   }
291   emptyMessageTemplate() {
292     let descriptionHeader;
293     let descriptionLabels;
294     let descriptionLink;
295     if (Services.prefs.getBoolPref(NEVER_REMEMBER_HISTORY_PREF, false)) {
296       // History pref set to never remember history
297       descriptionHeader = "firefoxview-dont-remember-history-empty-header-2";
298       descriptionLabels = [
299         "firefoxview-dont-remember-history-empty-description-one",
300       ];
301       descriptionLink = {
302         url: "about:preferences#privacy",
303         name: "history-settings-url-two",
304       };
305     } else {
306       descriptionHeader = "firefoxview-recentlyclosed-empty-header";
307       descriptionLabels = [
308         "firefoxview-recentlyclosed-empty-description",
309         "firefoxview-recentlyclosed-empty-description-two",
310       ];
311       descriptionLink = {
312         url: "about:firefoxview#history",
313         name: "history-url",
314         sameTarget: "true",
315       };
316     }
317     return html`
318       <fxview-empty-state
319         headerLabel=${descriptionHeader}
320         .descriptionLabels=${descriptionLabels}
321         .descriptionLink=${descriptionLink}
322         class="empty-state recentlyclosed"
323         ?isInnerCard=${this.recentBrowsing}
324         ?isSelectedTab=${this.selectedTab}
325         mainImageUrl="chrome://browser/content/firefoxview/history-empty.svg"
326       >
327       </fxview-empty-state>
328     `;
329   }
331   render() {
332     return html`
333       <link
334         rel="stylesheet"
335         href="chrome://browser/content/firefoxview/firefoxview.css"
336       />
337       ${when(
338         !this.recentBrowsing,
339         () =>
340           html`<div
341             class="sticky-container bottom-fade"
342             ?hidden=${!this.selectedTab}
343           >
344             <h2
345               class="page-header"
346               data-l10n-id="firefoxview-recently-closed-header"
347             ></h2>
348             <div>
349               <fxview-search-textbox
350                 data-l10n-id="firefoxview-search-text-box-recentlyclosed"
351                 data-l10n-attrs="placeholder"
352                 @fxview-search-textbox-query=${this.onSearchQuery}
353                 .size=${this.searchTextboxSize}
354                 pageName=${this.recentBrowsing
355                   ? "recentbrowsing"
356                   : "recentlyclosed"}
357               ></fxview-search-textbox>
358             </div>
359           </div>`
360       )}
361       <div class=${classMap({ "cards-container": this.selectedTab })}>
362         <card-container
363           shortPageName=${this.recentBrowsing ? "recentlyclosed" : null}
364           ?showViewAll=${this.recentBrowsing && this.recentlyClosedTabs.length}
365           ?preserveCollapseState=${this.recentBrowsing ? true : null}
366           ?hideHeader=${this.selectedTab}
367           ?hidden=${!this.recentlyClosedTabs.length && !this.recentBrowsing}
368           ?isEmptyState=${!this.recentlyClosedTabs.length}
369         >
370           <h3
371             slot="header"
372             data-l10n-id="firefoxview-recently-closed-header"
373           ></h3>
374           ${when(
375             this.recentlyClosedTabs.length,
376             () => html`
377               <fxview-tab-list
378                 slot="main"
379                 .maxTabsLength=${!this.recentBrowsing || this.showAll
380                   ? -1
381                   : MAX_TABS_FOR_RECENT_BROWSING}
382                 .searchQuery=${ifDefined(
383                   this.searchResults && this.searchQuery
384                 )}
385                 .tabItems=${this.searchResults || this.recentlyClosedTabs}
386                 @fxview-tab-list-secondary-action=${this.onDismissTab}
387                 @fxview-tab-list-primary-action=${this.onReopenTab}
388                 secondaryActionClass="dismiss-button"
389               ></fxview-tab-list>
390             `
391           )}
392           ${when(
393             this.recentBrowsing && !this.recentlyClosedTabs.length,
394             () => html` <div slot="main">${this.emptyMessageTemplate()}</div> `
395           )}
396           ${when(
397             this.isShowAllLinkVisible(),
398             () =>
399               html` <div
400                 @click=${this.enableShowAll}
401                 @keydown=${this.enableShowAll}
402                 data-l10n-id="firefoxview-show-all"
403                 ?hidden=${!this.isShowAllLinkVisible()}
404                 slot="footer"
405                 tabindex="0"
406                 role="link"
407               ></div>`
408           )}
409         </card-container>
410         ${when(
411           this.selectedTab && !this.recentlyClosedTabs.length,
412           () => html` <div>${this.emptyMessageTemplate()}</div> `
413         )}
414       </div>
415     `;
416   }
418   onSearchQuery(e) {
419     this.searchQuery = e.detail.query;
420     this.showAll = false;
421     this.cumulativeSearches = this.searchQuery
422       ? this.cumulativeSearches + 1
423       : 0;
424     this.#updateSearchResults();
425   }
427   #updateSearchResults() {
428     this.searchResults = this.searchQuery
429       ? searchTabList(this.searchQuery, this.recentlyClosedTabs)
430       : null;
431   }
433   isShowAllLinkVisible() {
434     return (
435       this.recentBrowsing &&
436       this.searchQuery &&
437       this.searchResults.length > MAX_TABS_FOR_RECENT_BROWSING &&
438       !this.showAll
439     );
440   }
442   enableShowAll(event) {
443     if (
444       event.type == "click" ||
445       (event.type == "keydown" && event.code == "Enter") ||
446       (event.type == "keydown" && event.code == "Space")
447     ) {
448       event.preventDefault();
449       this.showAll = true;
450       Glean.firefoxviewNext.searchShowAllShowallbutton.record({
451         section: "recentlyclosed",
452       });
453     }
454   }
456 customElements.define("view-recentlyclosed", RecentlyClosedTabsInView);